- Not a local Python EP. Select to build web-oriented recipes, then use{" "}
- Recipe & run → Browser Test / WebGPU benchmark in
- Chrome or Edge 113+.
-
- ) : null}
- {isExportTarget && !isWebGpuTarget ? (
-
- {isLegacyTarget
- ? "Legacy export path (prefer QNN for Snapdragon). Not available for Studio Execute Live."
- : "Export / deploy target only. Not a local Python EP, so Execute Live stays blocked."}
-
- ) : null}
- {isPlatformTarget && !detectedLocally && !probeLoading ? (
-
- Platform EP: selectable for recipes; Execute Live requires a matching ORT probe hit on this host.
-
- ) : null}
- {!detectedLocally &&
- !probeLoading &&
- !isExportTarget &&
- !isPlatformTarget &&
- !needsPluginInstall ? (
-
- {p.id === "CPUExecutionProvider"
- ? "Hardware detection unavailable. CPU status is unknown."
- : "No matching hardware found locally. You can still select for remote/cross-compile targets."}
-
- ) : null}
+
+ {detectedLocally && hardwareDetail ? (
+
{hardwareDetail}
+ ) : null}
+
+ {p.id === "OpenVINOExecutionProvider" ? (
+
+ ) : null}
+ {isWebGpuTarget ? (
+
+ Not a local Python EP. Select to build web-oriented recipes, then use{" "}
+ Recipe & run → Browser Test / WebGPU benchmark in
+ Chrome or Edge 113+.
+
+ ) : null}
+ {isExportTarget && !isWebGpuTarget ? (
+
+ {isLegacyTarget
+ ? "Legacy export path (prefer QNN for Snapdragon). Not available for Studio Execute Live."
+ : "Export / deploy target only. Not a local Python EP, so Execute Live stays blocked."}
+
+ ) : null}
+ {isPlatformTarget && !detectedLocally && !probeLoading ? (
+
+ Platform EP: selectable for recipes; Execute Live requires a matching ORT probe hit on this host.
+
+ ) : null}
+ {!detectedLocally &&
+ !probeLoading &&
+ !isExportTarget &&
+ !isPlatformTarget &&
+ !needsPluginInstall ? (
+
+ {p.id === "CPUExecutionProvider"
+ ? "Hardware detection unavailable. CPU status is unknown."
+ : "No matching hardware found locally. You can still select for remote/cross-compile targets."}
+
+ ) : null}
+
+ )}
diff --git a/src/components/features/ihv/ProviderCardGrid.test.tsx b/src/components/features/ihv/ProviderCardGrid.test.tsx
new file mode 100644
index 00000000..b5b1ed01
--- /dev/null
+++ b/src/components/features/ihv/ProviderCardGrid.test.tsx
@@ -0,0 +1,62 @@
+import { describe, it, expect, vi } from "vitest";
+import { screen, render } from "@testing-library/react";
+import { ProviderCardGrid } from "./ProviderCardGrid";
+import { createMockUIState } from "../__tests__/testUtils";
+import { PROVIDER_CATALOG } from "@/lib/providerCatalog";
+import { getProviderRuntimeKind } from "@/lib/providerRuntimeKind";
+import type { IHVProvider } from "@/types";
+
+describe("ProviderCardGrid", () => {
+ const defaultProps = {
+ probeLoading: false,
+ localAccelerators: PROVIDER_CATALOG.filter((p) => getProviderRuntimeKind(p.id) === "local"),
+ exportAndPlatformTargets: PROVIDER_CATALOG.filter((p) => getProviderRuntimeKind(p.id) !== "local"),
+ providerCardProps: {
+ state: createMockUIState({ ihvProvider: "CPUExecutionProvider" }),
+ setState: vi.fn(),
+ hardwareProbe: null,
+ probeLoading: false,
+ detectedProviders: ["CPUExecutionProvider" as IHVProvider],
+ trtRtxNeedsInstall: false,
+ trtNeedsInstall: false,
+ openvinoNeedsInstall: false,
+ hardwareInstallBusy: false,
+ installingTrtRtx: false,
+ installTrtRtxError: null,
+ installTrtRtxLog: [],
+ onInstallTensorRtRtx: vi.fn(),
+ installingTrt: false,
+ installTrtError: null,
+ installTrtLog: [],
+ onInstallTensorRt: vi.fn(),
+ openvinoInstall: { state: { installing: false, error: null, log: [] }, install: vi.fn() },
+ qnnInstall: { state: { installing: false, testing: false, error: null, log: [] }, install: vi.fn(), testNpu: vi.fn() },
+ directMlInstall: { state: { installing: false, error: null, log: [] }, install: vi.fn() },
+ isPreMaxwellBox: false,
+ cudaNeedsOrtGpuInstall: false,
+ cudaToolkitMissingAndEpWorks: false,
+ cudaToolkitMissing: false,
+ cudaEpInVenv: false,
+ nvidiaGpus: [],
+ installingOrtGpu: false,
+ installOrtGpuError: null,
+ installOrtGpuLog: [],
+ onInstallOrtGpu: vi.fn(),
+ },
+ };
+
+ it("does not classify CPU as hardware-blocked when hardwareProbe is null", () => {
+ render(
);
+
+ // CPU provider header
+ const cpuHeader = screen.getByText("Native CPU");
+ expect(cpuHeader).toBeTruthy();
+
+ // The container for Native CPU should contain "Active Target" badge
+ const cpuCard = cpuHeader.closest(".relative");
+ expect(cpuCard).toBeTruthy();
+ expect(cpuCard?.textContent).toContain("Active Target");
+ expect(cpuCard?.textContent).not.toContain("Unavailable hardware");
+ expect(cpuCard?.textContent).not.toContain("Not on this system");
+ });
+});
diff --git a/src/components/features/ihv/ProviderCardGrid.tsx b/src/components/features/ihv/ProviderCardGrid.tsx
index 837fea89..b95e27ba 100644
--- a/src/components/features/ihv/ProviderCardGrid.tsx
+++ b/src/components/features/ihv/ProviderCardGrid.tsx
@@ -2,11 +2,12 @@
* ProviderCardGrid — Provider card grid with local accelerators and export/platform sections.
* Extracted from IHVIntegrationPanel (Task 6).
*/
-import { useState, useEffect } from "react";
+import { useState, useEffect, useMemo } from "react";
import { TooltipProvider } from "@/components/ui/Tooltip";
import { HardwareProviderCard, type HardwareProviderCardProps } from "./HardwareProviderCard";
import { ChevronDown, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
+import { isProviderDetectedLocally } from "@/lib/hardwareProbe";
import type { ProviderCatalogEntry } from "@/lib/providerCatalog";
export interface ProviderCardGridProps {
@@ -34,6 +35,37 @@ export function ProviderCardGrid({
setShowExportTargets(selectedIsExportTarget);
}, [selectedIsExportTarget]);
+ // Within local accelerators, cards for hardware not actually detected on this machine
+ // (e.g. CoreML on a Windows box) are rarely the pick — collapse them the same way
+ // export/platform targets collapse, unless the active selection is one of them.
+ // However, if probe is still loading or failed (null), show all locals together since we
+ // can't reliably distinguish detected from undetected without probe results.
+ const { detectedLocal, undetectedLocal } = useMemo(() => {
+ const hardwareProbe = providerCardProps.hardwareProbe;
+ const detected: ProviderCatalogEntry[] = [];
+ const undetected: ProviderCatalogEntry[] = [];
+
+ // Only split if probe data is available; otherwise treat all as detected since detection is unavailable.
+ if (!hardwareProbe && !probeLoading) {
+ // Probe finished but returned null (error case) — show all, don't split
+ return { detectedLocal: localAccelerators, undetectedLocal: [] };
+ }
+
+ for (const p of localAccelerators) {
+ if (isProviderDetectedLocally(p.id, hardwareProbe)) detected.push(p);
+ else undetected.push(p);
+ }
+ return { detectedLocal: detected, undetectedLocal: undetected };
+ }, [localAccelerators, providerCardProps.hardwareProbe, probeLoading]);
+
+ const selectedIsUndetectedLocal = undetectedLocal.some(
+ (p) => p.id === providerCardProps.state.ihvProvider,
+ );
+ const [showUndetectedLocal, setShowUndetectedLocal] = useState(selectedIsUndetectedLocal);
+ useEffect(() => {
+ setShowUndetectedLocal(selectedIsUndetectedLocal);
+ }, [selectedIsUndetectedLocal]);
+
if (probeLoading) {
return (
@@ -51,10 +83,37 @@ export function ProviderCardGrid({
Local accelerators
- {localAccelerators.map((p) => (
+ {detectedLocal.map((p) => (
))}
+ {undetectedLocal.length > 0 && (
+ <>
+
+ {showUndetectedLocal && (
+
+ {undetectedLocal.map((p) => (
+
+ ))}
+
+ )}
+ >
+ )}
diff --git a/src/components/features/input/CompatStatus.tsx b/src/components/features/input/CompatStatus.tsx
index a03cb763..efffd2e1 100644
--- a/src/components/features/input/CompatStatus.tsx
+++ b/src/components/features/input/CompatStatus.tsx
@@ -4,12 +4,13 @@ import type { RecipeHardwareCompatTier } from "@/lib/recipeHardwareCompatibility
const TIER_UI: Record<
RecipeHardwareCompatTier,
- { label: string; Icon: typeof CheckCircle2; className: string }
+ { label: string; Icon: typeof CheckCircle2; className: string; iconClassName?: string }
> = {
compatible: {
label: "Compatible",
Icon: CheckCircle2,
- className: "text-emerald-400 border-emerald-500/30 bg-emerald-500/10",
+ className: "text-slate-300 border-slate-700 bg-slate-900/60",
+ iconClassName: "text-emerald-400",
},
unavailable: {
label: "Incompatible",
@@ -37,7 +38,7 @@ interface CompatStatusPillProps {
* @param size - The badge size
*/
export function CompatStatusPill({ tier, className, size = "sm" }: CompatStatusPillProps) {
- const { label, Icon, className: tierClass } = TIER_UI[tier];
+ const { label, Icon, className: tierClass, iconClassName } = TIER_UI[tier];
return (
-
+
{label}
);
diff --git a/src/components/features/playground/PlaygroundPanel.test.tsx b/src/components/features/playground/PlaygroundPanel.test.tsx
index 9e98dd1d..26f125d3 100644
--- a/src/components/features/playground/PlaygroundPanel.test.tsx
+++ b/src/components/features/playground/PlaygroundPanel.test.tsx
@@ -8,7 +8,11 @@ import { PlaygroundPanel } from "./PlaygroundPanel";
import { usePlaygroundStore } from "@/lib/stores/playgroundStore";
vi.mock("./InBrowserValidation", () => ({
- InBrowserValidation: () =>
Browser Test Panel
,
+ InBrowserValidation: ({ recipeJson }: { recipeJson?: string }) => (
+
+ Browser Test Panel
+
+ ),
}));
vi.mock("./WebGpuBenchmarkPanel", () => ({
@@ -86,4 +90,47 @@ describe("PlaygroundPanel", () => {
true,
);
});
+
+ it("passes capturedRunRecipe to InBrowserValidation only if it uses WebGpuExecutionProvider", async () => {
+ const cudaRecipe = JSON.stringify({
+ systems: {
+ gpu: {
+ config: {
+ accelerators: [{ execution_providers: ["CUDAExecutionProvider"] }],
+ },
+ },
+ },
+ });
+
+ usePlaygroundStore.getState().setCapturedRunRecipe(cudaRecipe);
+ const { unmount } = render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-test-panel")).toBeTruthy();
+ });
+
+ // CUDA captured recipe should NOT be passed to Browser Test
+ expect(screen.getByTestId("browser-test-panel").getAttribute("data-recipe-json")).toBe("");
+ unmount();
+
+ const webGpuRecipe = JSON.stringify({
+ systems: {
+ gpu: {
+ config: {
+ accelerators: [{ execution_providers: ["WebGpuExecutionProvider"] }],
+ },
+ },
+ },
+ });
+
+ usePlaygroundStore.getState().setCapturedRunRecipe(webGpuRecipe);
+ render(
);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("browser-test-panel")).toBeTruthy();
+ });
+
+ // WebGPU captured recipe SHOULD be passed to Browser Test
+ expect(screen.getByTestId("browser-test-panel").getAttribute("data-recipe-json")).toBe(webGpuRecipe);
+ });
});
diff --git a/src/components/features/playground/PlaygroundPanel.tsx b/src/components/features/playground/PlaygroundPanel.tsx
index 3964dbed..4fcdabc4 100644
--- a/src/components/features/playground/PlaygroundPanel.tsx
+++ b/src/components/features/playground/PlaygroundPanel.tsx
@@ -6,6 +6,7 @@ import { usePlaygroundStore, type PlaygroundSubView } from "@/lib/stores/playgro
import { usePipelineState } from "@/lib/stores/pipelineStore";
import { buildRecipeJsonFromState } from "@/lib/recipePipeline";
import { hasSelectedModel } from "@/lib/pipelineValidation";
+import { mapExecutionProviderFromRecipe } from "@/lib/oliveRecipeHub";
/* ------------------------------------------------------------------ */
/* Lazy sub-view imports */
@@ -58,17 +59,34 @@ function LoadingFallback({ label }: { label: string }) {
/* PlaygroundPanel */
/* ------------------------------------------------------------------ */
+function isWebGpuRecipe(recipeJson: string): boolean {
+ try {
+ const parsed = JSON.parse(recipeJson);
+ return mapExecutionProviderFromRecipe(parsed) === "WebGpuExecutionProvider";
+ } catch {
+ return false;
+ }
+}
+
export function PlaygroundPanel() {
const activeSubView = usePlaygroundStore((s) => s.activeSubView);
const setActiveSubView = usePlaygroundStore((s) => s.setActiveSubView);
+ const capturedRunRecipe = usePlaygroundStore((s) => s.capturedRunRecipe);
const { state } = usePipelineState();
- // Ties the Browser Test hint back to the recipe actually configured in steps 01-03,
- // instead of always being hidden (it was previously hardcoded to undefined).
+ // Memoize captured WebGPU recipe check so JSON parsing doesn't re-run on frequent pipeline state edits.
+ const isCapturedWebGpu = useMemo(() => {
+ return Boolean(capturedRunRecipe && isWebGpuRecipe(capturedRunRecipe));
+ }, [capturedRunRecipe]);
+
+ // Use the recipe from the last successful Execute run if available (preserves context even if
+ // user modifies state afterward). Fallback to building from current state for manual Browser Test.
// Only pass recipe if provider is WebGPU; other providers should not show this hint.
- const recipeJson = useMemo(
- () => (hasSelectedModel(state) && state.ihvProvider === "WebGpuExecutionProvider" ? buildRecipeJsonFromState(state) : undefined),
- [state],
- );
+ const recipeJson = useMemo(() => {
+ if (isCapturedWebGpu && capturedRunRecipe) return capturedRunRecipe;
+ return hasSelectedModel(state) && state.ihvProvider === "WebGpuExecutionProvider"
+ ? buildRecipeJsonFromState(state)
+ : undefined;
+ }, [isCapturedWebGpu, capturedRunRecipe, state]);
// Keep-alive: seed from store so remounts don't blank a restored sub-view
const [visitedSubViews, setVisitedSubViews] = useState
>(
diff --git a/src/lib/hardwareProbe.test.ts b/src/lib/hardwareProbe.test.ts
index 629ed139..10d29e3b 100644
--- a/src/lib/hardwareProbe.test.ts
+++ b/src/lib/hardwareProbe.test.ts
@@ -6,6 +6,7 @@ import {
computeQnnCompatibleHardware,
getProviderAvailabilityBlock,
isNvidiaGpuTensorRtFamily,
+ isProviderDetectedLocally,
mergeDetectedProviders,
parseComputeCapability,
pickRecommendedProvider,
@@ -655,3 +656,27 @@ describe("mergeDetectedProviders QNN", () => {
).toBe(false);
});
});
+
+describe("isProviderDetectedLocally", () => {
+ it("always detects CPUExecutionProvider locally even when probe is null", () => {
+ expect(isProviderDetectedLocally("CPUExecutionProvider", null)).toBe(true);
+ expect(isProviderDetectedLocally("CPUExecutionProvider", undefined)).toBe(true);
+ });
+
+ it("checks probe detectedProviders for GPU providers", () => {
+ const probe = {
+ probedAt: "now",
+ platform: { os: "win", arch: "x64", cpuModel: "CPU", cpuCores: 4 },
+ detectedProviders: ["CPUExecutionProvider", "CUDAExecutionProvider"],
+ recommendedProvider: "CUDAExecutionProvider",
+ notes: [],
+ } as HardwareProbeResult;
+
+ expect(isProviderDetectedLocally("CUDAExecutionProvider", probe)).toBe(true);
+ expect(isProviderDetectedLocally("ROCMExecutionProvider", probe)).toBe(false);
+ });
+
+ it("returns false for non-CPU providers when probe is null", () => {
+ expect(isProviderDetectedLocally("CUDAExecutionProvider", null)).toBe(false);
+ });
+});
diff --git a/src/lib/hardwareProbe.ts b/src/lib/hardwareProbe.ts
index 5ffe2e98..ab53ab81 100644
--- a/src/lib/hardwareProbe.ts
+++ b/src/lib/hardwareProbe.ts
@@ -565,6 +565,9 @@ export function isProviderDetectedLocally(
provider: IHVProvider,
probe: HardwareProbeResult | null | undefined,
): boolean {
+ if (provider === "CPUExecutionProvider") {
+ return true;
+ }
if (!probe) {
return false;
}
diff --git a/src/lib/hooks/useMcpDiagnostic.ts b/src/lib/hooks/useMcpDiagnostic.ts
index 1a24757d..2f3b8172 100644
--- a/src/lib/hooks/useMcpDiagnostic.ts
+++ b/src/lib/hooks/useMcpDiagnostic.ts
@@ -18,6 +18,8 @@ interface UseMcpDiagnosticReturn {
* they need (single state, keyed Record, etc.).
*/
fetchDiagnostic: (logs: string[]) => Promise;
+ /** Clear the stored diagnostic and abort any in-flight fetch. */
+ clearDiagnostic: () => void;
}
/**
@@ -67,7 +69,14 @@ export function useMcpDiagnostic(): UseMcpDiagnosticReturn {
}
}, []);
- return { diagnostic, isDiagnosing, error, fetchDiagnostic };
+ const clearDiagnostic = useCallback(() => {
+ abortRef.current?.abort();
+ setDiagnostic(null);
+ setError(null);
+ setIsDiagnosing(false);
+ }, []);
+
+ return { diagnostic, isDiagnosing, error, fetchDiagnostic, clearDiagnostic };
}
/**
diff --git a/src/lib/oliveRecipeHub.ts b/src/lib/oliveRecipeHub.ts
index 7b06f89f..db9e5c5e 100644
--- a/src/lib/oliveRecipeHub.ts
+++ b/src/lib/oliveRecipeHub.ts
@@ -192,7 +192,7 @@ export async function fetchOliveRecipesCatalogItem(
*
* @returns The first recognized execution provider, or `undefined` when the recipe has no recognized provider.
*/
-function mapExecutionProviderFromRecipe(parsed: unknown): IHVProvider | undefined {
+export function mapExecutionProviderFromRecipe(parsed: unknown): IHVProvider | undefined {
const recipe = parsed as Record | undefined;
const systems = recipe?.systems as Record | undefined;
if (systems && typeof systems === "object") {
diff --git a/src/lib/stores/playgroundStore.ts b/src/lib/stores/playgroundStore.ts
index 2323218b..d3f0e5c4 100644
--- a/src/lib/stores/playgroundStore.ts
+++ b/src/lib/stores/playgroundStore.ts
@@ -40,6 +40,9 @@ interface PlaygroundStore {
slotB: ArenaSlotConfig;
setSlotA: (patch: Partial) => void;
setSlotB: (patch: Partial) => void;
+ /** Recipe JSON captured from the last successful Execute run. */
+ capturedRunRecipe: string | undefined;
+ setCapturedRunRecipe: (recipe: string | undefined) => void;
/** Reset session-scoped Playground fields (does not touch pipeline state). */
resetPlayground: () => void;
}
@@ -51,10 +54,13 @@ export const usePlaygroundStore = create((set) => ({
slotB: defaultArenaSlot(),
setSlotA: (patch) => set((s) => ({ slotA: { ...s.slotA, ...patch } })),
setSlotB: (patch) => set((s) => ({ slotB: { ...s.slotB, ...patch } })),
+ capturedRunRecipe: undefined,
+ setCapturedRunRecipe: (recipe) => set({ capturedRunRecipe: recipe }),
resetPlayground: () =>
set({
activeSubView: "browser-test",
slotA: defaultArenaSlot(),
slotB: defaultArenaSlot(),
+ capturedRunRecipe: undefined,
}),
}));