diff --git a/.changeset/fn-7476-desktop-engine-banner.md b/.changeset/fn-7476-desktop-engine-banner.md
new file mode 100644
index 0000000000..5b29ed00c6
--- /dev/null
+++ b/.changeset/fn-7476-desktop-engine-banner.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Fix a false AI engine not running banner in desktop mode.
+category: fix
+dev: Distinguishes transient embedded desktop engine startup from true dashboard-only mode.
diff --git a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
index 004b588219..5918a9396c 100644
--- a/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
+++ b/packages/dashboard/app/components/dashboard/__tests__/DashboardBanners.test.tsx
@@ -331,6 +331,24 @@ describe("DashboardBanners engine remediation visibility", () => {
expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(2);
});
+ it("does not mount the dashboard-only unavailable banner when desktop health reports a startable manager", () => {
+ render(
+ ,
+ );
+
+ expect(screen.queryByTestId("engine-unavailable-banner")).not.toBeInTheDocument();
+ expect(screen.getByTestId("engine-status-banner")).toBeInTheDocument();
+ expect(screen.getAllByRole("button", { name: /start engine/i })).toHaveLength(1);
+ });
+
it("preserves the current project guard while auth recovery is closed", () => {
render();
diff --git a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts
index e865938de9..d6daf0cbf3 100644
--- a/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts
+++ b/packages/dashboard/app/hooks/__tests__/useDashboardHealth.test.ts
@@ -1,4 +1,4 @@
-import { beforeEach, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderHook, act, waitFor } from "@testing-library/react";
const fetchDashboardHealth = vi.fn();
@@ -16,6 +16,10 @@ describe("useDashboardHealth", () => {
refreshDashboardHealth.mockReset();
});
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
it("seeds health from the mount fetch and falls back to null on failure", async () => {
fetchDashboardHealth.mockResolvedValue({ status: "ok" });
const { result } = renderHook(() => useDashboardHealth());
@@ -59,6 +63,24 @@ describe("useDashboardHealth", () => {
expect(result.current.refreshError).toBe("nope");
expect(result.current.refreshing).toBe(false);
});
+
+ it("polls health so a transient unavailable desktop startup response is replaced", async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
+ fetchDashboardHealth
+ .mockResolvedValueOnce({ status: "ok", engine: { available: false } })
+ .mockResolvedValueOnce({ status: "ok", engine: { available: true } });
+
+ const { result } = renderHook(() => useDashboardHealth());
+
+ await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: false } }));
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(15_000);
+ });
+
+ await waitFor(() => expect(result.current.health).toEqual({ status: "ok", engine: { available: true } }));
+ expect(fetchDashboardHealth).toHaveBeenCalledTimes(2);
+ });
it("fires the mount fetch and tolerates an unmount before it resolves", async () => {
let resolveMount: (value: { status: string }) => void = () => {};
fetchDashboardHealth.mockImplementation(
diff --git a/packages/dashboard/src/__tests__/server.test.ts b/packages/dashboard/src/__tests__/server.test.ts
index a01cc04e0f..c7c966da73 100644
--- a/packages/dashboard/src/__tests__/server.test.ts
+++ b/packages/dashboard/src/__tests__/server.test.ts
@@ -462,14 +462,9 @@ describe("createServer health and headless mode", () => {
});
});
- it("reports the engine unavailable when the manager has no running engines", async () => {
+ it("reports the engine unavailable when no engine manager can run automation", async () => {
const store = createMockStore();
- const app = createServer(store, {
- engineManager: {
- getAllEngines: vi.fn().mockReturnValue(new Map()),
- getEngine: vi.fn(),
- } as any,
- });
+ const app = createServer(store);
const res = await GET(app, "/api/health");
@@ -477,6 +472,27 @@ describe("createServer health and headless mode", () => {
expect(res.body.engine).toEqual({ available: false });
});
+ it("keeps desktop embedded managers available while engines are still starting or lazily startable", async () => {
+ const store = createMockStore();
+ const app = createServer(store, {
+ engineManager: {
+ getAllEngines: vi.fn().mockReturnValue(new Map()),
+ hasRunningEngine: vi.fn().mockReturnValue(false),
+ getEngine: vi.fn(),
+ has: vi.fn((projectId: string) => projectId === "starting"),
+ } as any,
+ });
+
+ const health = await GET(app, "/api/health");
+ const startingStatus = await GET(app, "/api/engine/status?projectId=starting");
+ const lazilyStartableStatus = await GET(app, "/api/engine/status?projectId=missing");
+
+ expect(health.status).toBe(200);
+ expect(health.body.engine).toEqual({ available: true });
+ expect(startingStatus.body).toEqual({ connected: false, starting: true, canStart: true, projectId: "starting" });
+ expect(lazilyStartableStatus.body).toEqual({ connected: false, starting: false, canStart: true, projectId: "missing" });
+ });
+
it("reports the engine available when the manager has a running engine", async () => {
const store = createMockStore();
const engine = {
diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts
index 9652202869..3ed2acf858 100644
--- a/packages/dashboard/src/server.ts
+++ b/packages/dashboard/src/server.ts
@@ -501,11 +501,14 @@ function hasDashboardEngine(options?: ServerOptions): boolean {
* by this process AND engines owned by another fusion process on the machine
* (detected via the singleton lock); without the latter a UI-only launch
* alongside an already-running engine shows a false "engine not running"
- * banner. Fall back to the owned-engine map for older manager instances /
- * test doubles.
+ * banner.
+ *
+ * FNXC:DesktopEngineAvailability 2026-07-03-16:15:
+ * Desktop embedded local mode creates modern ProjectEngineManager before any project engine may exist.
+ * Treating an empty owned-engine map as dashboard-only makes the app shell show restart-Fusion remediation while the same manager is still starting, reconciling, or able to lazily ensure the selected project engine. A manager with machine-level liveness support therefore means automation is available at the process level; project-scoped disconnected/starting details stay on /api/engine/status. Older manager test doubles without that method retain the historical owned-engine fallback.
*/
if (typeof manager.hasRunningEngine === "function") {
- return manager.hasRunningEngine();
+ return true;
}
const engines = manager.getAllEngines?.();
return Boolean(engines && engines.size > 0);