Skip to content

Commit f3dc191

Browse files
committed
feat(dashboard): block snapshot when no provider configured, show banner
New Rust command is_provider_ready: pure config check (no network call) against the active provider. Checks DB provider_has_key flag for API-key providers, model selection for all, and endpoint for Ollama. Returns ready + reason string + active_provider id. Dashboard: on mount + window focus, invokes is_provider_ready and stores result. When not ready, shows an amber banner (severity-medium colors) between TopBar and tabs with the reason, "Configure in Settings" helper text, and a direct link to /settings. Snapshot button gains snapshotBlocked prop via TopBar; when blocked: disabled, muted style, tooltip "Configure an AI provider first". Auto-snapshot useEffect now calls is_provider_ready before mutating; skips the snapshot (and discards the flag) if provider not ready. Onboarding completeWithSnapshot: checks is_provider_ready before setting the sessionStorage flag; if not ready, falls through to complete() so user lands on Dashboard with the banner explaining the next step. TopBar: new snapshotBlocked? prop threads the disabled state without changing the button's visibility.
1 parent 5554406 commit f3dc191

5 files changed

Lines changed: 191 additions & 10 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,79 @@ fn read_bytes_freed_from_audit_log() -> u64 {
520520
total
521521
}
522522

523+
// ── Provider readiness check ─────────────────────────────────────────────────
524+
525+
#[derive(serde::Serialize)]
526+
struct ProviderReadiness {
527+
ready: bool,
528+
reason: Option<String>,
529+
active_provider: String,
530+
}
531+
532+
#[tauri::command]
533+
async fn is_provider_ready(db: State<'_, Db>) -> Result<ProviderReadiness, String> {
534+
let db = db.inner().clone();
535+
tokio::task::spawn_blocking(move || -> Result<ProviderReadiness, String> {
536+
let config = provider_config::ProviderConfig::load(&db)
537+
.map_err(|e: crate::error::AppError| e.to_string())?;
538+
let active = config.active_provider.clone();
539+
let active_str = active.as_str().to_string();
540+
541+
let has_key = if active.keychain_account().is_some() {
542+
let flag_key = format!("provider_has_key:{}", active.as_str());
543+
db.get_setting(&flag_key)
544+
.map(|v| v.as_deref() == Some("1"))
545+
.unwrap_or(false)
546+
} else {
547+
true
548+
};
549+
550+
let (ready, reason) = match active {
551+
provider_config::ProviderId::ClaudeCli => (true, None),
552+
provider_config::ProviderId::AnthropicApi => {
553+
if !has_key {
554+
(false, Some("Anthropic API key not set".to_string()))
555+
} else if config.anthropic_api.model.is_empty() {
556+
(false, Some("Anthropic model not selected".to_string()))
557+
} else {
558+
(true, None)
559+
}
560+
}
561+
provider_config::ProviderId::OpenAi => {
562+
if !has_key {
563+
(false, Some("OpenAI API key not set".to_string()))
564+
} else if config.openai.model.is_empty() {
565+
(false, Some("OpenAI model not selected".to_string()))
566+
} else {
567+
(true, None)
568+
}
569+
}
570+
provider_config::ProviderId::Gemini => {
571+
if !has_key {
572+
(false, Some("Gemini API key not set".to_string()))
573+
} else if config.gemini.model.is_empty() {
574+
(false, Some("Gemini model not selected".to_string()))
575+
} else {
576+
(true, None)
577+
}
578+
}
579+
provider_config::ProviderId::Ollama => {
580+
if config.ollama.endpoint.is_empty() {
581+
(false, Some("Ollama endpoint not set".to_string()))
582+
} else if config.ollama.model.is_empty() {
583+
(false, Some("Ollama model not selected".to_string()))
584+
} else {
585+
(true, None)
586+
}
587+
}
588+
};
589+
590+
Ok(ProviderReadiness { ready, reason, active_provider: active_str })
591+
})
592+
.await
593+
.map_err(|e| e.to_string())?
594+
}
595+
523596
// ── First-run onboarding commands ────────────────────────────────────────────
524597

525598
#[tauri::command]
@@ -751,6 +824,7 @@ pub fn run() {
751824
probe_automation_permission,
752825
probe_full_disk_access,
753826
open_system_settings_pane,
827+
is_provider_ready,
754828
])
755829
.run(tauri::generate_context!())
756830
.expect("error while running tauri application");

src/components/TopBar.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ interface TopBarProps {
99
activeSnapshotId?: number | null;
1010
findingCount?: number | null;
1111
isAnalyzing?: boolean;
12+
snapshotBlocked?: boolean;
1213
onTakeSnapshot?: () => void;
1314
onReAnalyze?: () => void;
1415
}
@@ -26,6 +27,7 @@ export default function TopBar({
2627
activeSnapshotId = null,
2728
findingCount = null,
2829
isAnalyzing = false,
30+
snapshotBlocked = false,
2931
onTakeSnapshot,
3032
onReAnalyze,
3133
}: TopBarProps) {
@@ -108,17 +110,18 @@ export default function TopBar({
108110
{onTakeSnapshot && (
109111
<button
110112
onClick={onTakeSnapshot}
111-
disabled={isAnalyzing}
113+
disabled={isAnalyzing || snapshotBlocked}
114+
title={snapshotBlocked ? "Configure an AI provider first" : undefined}
112115
style={{
113-
background: isAnalyzing ? "var(--color-text-muted)" : "var(--color-accent)",
114-
color: isAnalyzing ? "var(--color-text-disabled)" : "#1a1a26",
116+
background: (isAnalyzing || snapshotBlocked) ? "var(--color-text-muted)" : "var(--color-accent)",
117+
color: (isAnalyzing || snapshotBlocked) ? "var(--color-text-disabled)" : "#1a1a26",
115118
border: "none",
116119
borderRadius: "6px",
117120
padding: "6px 14px",
118121
fontFamily: "var(--font-sans)",
119122
fontSize: "12px",
120123
fontWeight: 500,
121-
cursor: isAnalyzing ? "not-allowed" : "pointer",
124+
cursor: (isAnalyzing || snapshotBlocked) ? "not-allowed" : "pointer",
122125
}}
123126
>
124127
{isAnalyzing ? "Analyzing…" : "Take snapshot"}

src/pages/Dashboard.tsx

Lines changed: 105 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
import { invoke } from "@tauri-apps/api/core";
3+
import { getCurrentWindow } from "@tauri-apps/api/window";
34
import { useState, useEffect, useRef, useCallback } from "react";
5+
import { Link } from "react-router-dom";
46
import { toast } from "sonner";
7+
import { AlertTriangle } from "lucide-react";
58
import type { Finding } from "../types/finding";
69
import type { AuditTokenUsage, PersistenceEntry, Snapshot } from "../types/snapshot";
710
import type { ProviderConfig } from "../types/provider";
@@ -103,6 +106,43 @@ export default function Dashboard() {
103106
.catch(() => {});
104107
}, []);
105108

109+
const [providerReady, setProviderReady] = useState<{
110+
ready: boolean;
111+
reason: string | null;
112+
active_provider: string;
113+
} | null>(null);
114+
115+
function checkProviderReady() {
116+
invoke<{ ready: boolean; reason: string | null; active_provider: string }>(
117+
"is_provider_ready"
118+
)
119+
.then(setProviderReady)
120+
.catch(() => {});
121+
}
122+
123+
useEffect(() => {
124+
checkProviderReady();
125+
}, []);
126+
127+
// Re-check readiness whenever the window regains focus (user may have updated Settings).
128+
useEffect(() => {
129+
let unlisten: (() => void) | null = null;
130+
let cancelled = false;
131+
getCurrentWindow()
132+
.onFocusChanged(({ payload: focused }) => {
133+
if (focused && !cancelled) checkProviderReady();
134+
})
135+
.then((fn) => {
136+
if (cancelled) fn();
137+
else unlisten = fn;
138+
})
139+
.catch(() => {});
140+
return () => {
141+
cancelled = true;
142+
unlisten?.();
143+
};
144+
}, []);
145+
106146
const latestIdQuery = useQuery<number | null>({
107147
queryKey: ["latest_snapshot_id"],
108148
queryFn: () => invoke<number | null>("latest_snapshot_id"),
@@ -187,11 +227,19 @@ export default function Dashboard() {
187227

188228
const hasRunAutoSnapshot = useRef(false);
189229
useEffect(() => {
190-
if (!hasRunAutoSnapshot.current && sessionStorage.getItem("mscope_auto_snapshot") === "1") {
191-
hasRunAutoSnapshot.current = true;
192-
sessionStorage.removeItem("mscope_auto_snapshot");
193-
runFullScan.mutate();
194-
}
230+
if (hasRunAutoSnapshot.current) return;
231+
const flag = sessionStorage.getItem("mscope_auto_snapshot");
232+
sessionStorage.removeItem("mscope_auto_snapshot");
233+
if (!flag) return;
234+
const ts = parseInt(flag, 10);
235+
if (Number.isNaN(ts) || Date.now() - ts > 5000) return;
236+
invoke<{ ready: boolean }>("is_provider_ready")
237+
.then(({ ready }) => {
238+
if (!ready) return;
239+
hasRunAutoSnapshot.current = true;
240+
runFullScan.mutate();
241+
})
242+
.catch(() => {});
195243
// eslint-disable-next-line react-hooks/exhaustive-deps
196244
}, []);
197245

@@ -339,9 +387,61 @@ export default function Dashboard() {
339387
activeSnapshotId={activeSnapshotId}
340388
findingCount={findings?.length ?? null}
341389
isAnalyzing={isAnalyzing}
390+
snapshotBlocked={providerReady !== null && !providerReady.ready}
342391
onTakeSnapshot={() => runFullScan.mutate()}
343392
onReAnalyze={() => reAnalyze.mutate()}
344393
/>
394+
{providerReady !== null && !providerReady.ready && (
395+
<div
396+
style={{
397+
display: "flex",
398+
alignItems: "center",
399+
gap: "10px",
400+
padding: "8px 20px",
401+
background: "var(--color-severity-medium-bg)",
402+
borderBottom: "1px solid var(--color-border-divider)",
403+
flexShrink: 0,
404+
}}
405+
>
406+
<AlertTriangle size={14} style={{ color: "var(--color-severity-medium-fg)", flexShrink: 0 }} />
407+
<span
408+
style={{
409+
flex: 1,
410+
fontSize: "var(--text-xs)",
411+
fontFamily: "var(--font-mono)",
412+
color: "var(--color-severity-medium-fg)",
413+
}}
414+
>
415+
{providerReady.reason}
416+
<span
417+
style={{
418+
color: "var(--color-text-muted)",
419+
marginLeft: "8px",
420+
fontFamily: "var(--font-sans)",
421+
}}
422+
>
423+
Configure in Settings to enable analysis.
424+
</span>
425+
</span>
426+
<Link
427+
to="/settings"
428+
style={{
429+
fontSize: "var(--text-xs)",
430+
fontFamily: "var(--font-sans)",
431+
fontWeight: 500,
432+
color: "var(--color-accent)",
433+
textDecoration: "none",
434+
padding: "3px 10px",
435+
background: "var(--color-accent-glow)",
436+
borderRadius: "var(--radius-sm)",
437+
border: "1px solid var(--color-accent-muted)",
438+
flexShrink: 0,
439+
}}
440+
>
441+
Configure
442+
</Link>
443+
</div>
444+
)}
345445
<TabBar
346446
active={active}
347447
onChange={setActive}

src/pages/Onboarding.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -434,7 +434,10 @@ export default function Onboarding({ onComplete }: OnboardingProps) {
434434
}
435435

436436
async function completeWithSnapshot() {
437-
sessionStorage.setItem("mscope_auto_snapshot", "1");
437+
const readiness = await invoke<{ ready: boolean }>("is_provider_ready").catch(() => ({ ready: false }));
438+
if (readiness.ready) {
439+
sessionStorage.setItem("mscope_auto_snapshot", String(Date.now()));
440+
}
438441
await complete();
439442
}
440443

src/pages/Settings.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,7 @@ function SectionDeveloper() {
335335
async function handleReset() {
336336
setResetting(true);
337337
try {
338+
sessionStorage.removeItem("mscope_auto_snapshot");
338339
await invoke("reset_app_state");
339340
toast.success("App state reset");
340341
navigate("/");

0 commit comments

Comments
 (0)