FN-7527: fix desktop server switching redirects

Route desktop shell server switches through the live runtime/profile state.

- Replace separate local/remote redirect effects with a shared resolver that uses localRuntime/baseUrl and active remote profiles.
- Remove the dead localServer shell state field and document the local/remote switch navigation behavior.
- Add regression coverage for desktop shell redirect targets and include a patch changeset.

Files changed:
 .changeset/fn-7527-desktop-switch-server-navigation.md    |   7 +
 docs/native-shell.md                               |   2 +-
 packages/dashboard/app/App.tsx                     |  44 ++---
 packages/dashboard/app/components/__tests__/App.test.tsx          |   1 -
 packages/dashboard/app/types/native-shell.d.ts     |  13 +-
 packages/dashboard/app/utils/__tests__/appLifecycle.test.ts       | 180 +++++++++++++++++++++
 packages/dashboard/app/utils/appLifecycle.ts       |  70 ++++++++
 7 files changed, 280 insertions(+), 37 deletions(-)

Fusion-Task-Id: FN-7527

Fusion-Task-Lineage: a0fe5cbc-120b-4a56-b791-48306223a636

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-04 13:43:59 -07:00
parent 87a700cd4d
commit 3b52a4d2d9
7 changed files with 280 additions and 37 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Fix the in-dashboard Switch server menu not switching desktop local/remote.
category: fix
dev: The desktop shell's redirect effects in App.tsx read a dead `localServer` field that the preload never populates; extracted `resolveDesktopShellRedirectTarget` in appLifecycle.ts now derives the navigation target from the live `localRuntime`/`activeProfileId` state for both directions, and the unused `localServer` field was removed from `ShellConnectionState`.

View File

@@ -57,7 +57,7 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag
Connection Manager supports:
- **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles.
- **Desktop Switch server** presents the built-in **Local Server** separately from saved **Remote servers**. Local Server is always available in the desktop shell; selecting it calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles. Choosing **Local Server** or a remote profile from this in-dashboard menu navigates the renderer to the selected server's origin (the running local runtime's `baseUrl`, or the remote profile's `serverUrl`) — the same end result as switching modes from the native desktop menu (FN-7527).
- **Add remote server** is the desktop CTA for saving another Fusion server profile. The remote profile editor stays collapsed until a user chooses **Add remote server** or edits an existing saved profile, so local-only desktop users do not see an empty setup form.
- **Use** (activate a saved remote profile). In desktop local mode, using a remote profile first switches desktop mode back to `remote`, then activates the selected profile.
- **Edit** (update name/URL/token)

View File

@@ -88,7 +88,7 @@ import { fetchTaskDetail, fetchWorkflowSteps } from "./api";
import {
SETUP_WARNING_DISMISSED_KEY,
RETRY_WARNING_RATIO,
buildRemoteDashboardUrl,
resolveDesktopShellRedirectTarget,
requiresNativeShellOnboarding,
shouldShowFirstEverBootLoader,
isSessionNeedingInputForBanner,
@@ -1190,39 +1190,23 @@ function AppInner() {
};
}, [shellHost.host, shellState.activeProfileId, shellState.desktopMode, shellState.host, shellState.profiles]);
/*
* FNXC:DesktopSwitchServer 2026-07-04-13:20:
* Single shared decision path for the in-dashboard "Switch server" navigation, covering BOTH directions
* (remote -> local and local -> remote). This mirrors the working native-menu / desktopLaunchMode flow by
* navigating the renderer to the selected server's origin, since the in-dashboard switch does not route
* through the Electron main-process launch-mode handlers. See resolveDesktopShellRedirectTarget in
* appLifecycle.ts for the pure decision logic and negative-state guards (not-running runtime, already-on-
* target origin, non-desktop hosts, missing active profile).
*/
useEffect(() => {
if (shellState.host !== "desktop-shell") {
if (typeof window === "undefined") {
return;
}
if (shellState.desktopMode !== "local") {
return;
}
if (shellState.localServer?.status !== "ready" || !shellState.localServer.port) {
return;
}
if (window.location.port === String(shellState.localServer.port)) {
return;
}
window.location.href = `http://localhost:${shellState.localServer.port}`;
}, [shellState]);
useEffect(() => {
if (shellState.host !== "desktop-shell" || shellState.desktopMode !== "remote") {
return;
}
const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId);
if (!activeProfile || typeof window === "undefined") {
return;
}
const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null);
if (window.location.href !== nextUrl) {
window.location.href = nextUrl;
const target = resolveDesktopShellRedirectTarget(shellState, window.location.href);
if (target) {
window.location.href = target;
}
}, [shellState]);

View File

@@ -221,7 +221,6 @@ const mockShellConnectionState = {
desktopMode: "local" as const,
profiles: [],
activeProfileId: null,
localServer: null,
};
const mockGetShellConnectionNativeResult = vi.fn(async () => ({

View File

@@ -24,11 +24,14 @@ export interface ShellConnectionState {
};
activeProfileId: string | null;
profiles: ShellConnectionProfile[];
localServer?: {
status: "idle" | "starting" | "ready" | "error";
port?: number;
error?: string | null;
};
/*
* FNXC:DesktopSwitchServer 2026-07-04-13:20:
* `localRuntime` is the only field the desktop preload/IPC ever populates for the embedded local server
* (see packages/desktop/src/ipc.ts). A previous `localServer` field here was never emitted by the shell and
* was removed after it caused the in-dashboard "Switch server" -> Local Server redirect to silently no-op
* (FN-7527); resolveDesktopShellRedirectTarget in appLifecycle.ts is the sole consumer of localRuntime for
* renderer-side navigation decisions.
*/
localRuntime?: {
source: "embedded-local" | "external-cli" | "none";
state: "stopped" | "starting" | "running" | "error";

View File

@@ -0,0 +1,180 @@
import { describe, expect, it } from "vitest";
import { buildRemoteDashboardUrl, resolveDesktopShellRedirectTarget } from "../appLifecycle";
describe("resolveDesktopShellRedirectTarget", () => {
const remoteProfile = {
id: "remote-1",
serverUrl: "https://fusionstudio:4040",
authToken: "tok-123",
};
it("returns null for non-desktop-shell hosts", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "web",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
expect(
resolveDesktopShellRedirectTarget(
{
host: "mobile-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("returns null when desktopMode is undefined", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
activeProfileId: null,
profiles: [],
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("resolves the local runtime origin (baseUrl) when switching remote -> local", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: "remote-1",
profiles: [remoteProfile],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123", port: 50123 },
},
"https://fusionstudio:4040/",
);
expect(target).toBe("http://127.0.0.1:50123");
});
it("falls back to localhost:<port> when localRuntime has no baseUrl", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", port: 50123 },
},
"https://fusionstudio:4040/",
);
expect(target).toBe("http://localhost:50123");
});
it("returns null when the local runtime is not running", () => {
for (const state of ["stopped", "starting", "error"] as const) {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state, baseUrl: "http://127.0.0.1:50123" },
},
"https://fusionstudio:4040/",
),
).toBeNull();
}
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: undefined,
},
"https://fusionstudio:4040/",
),
).toBeNull();
});
it("returns null when already on the local runtime origin", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: null,
profiles: [],
localRuntime: { state: "running", baseUrl: "http://127.0.0.1:50123" },
},
"http://127.0.0.1:50123/",
),
).toBeNull();
});
it("resolves buildRemoteDashboardUrl(...) when switching local -> a remote profile", () => {
const target = resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "remote-1",
profiles: [remoteProfile],
localRuntime: { state: "stopped" },
},
"http://127.0.0.1:50123/",
);
expect(target).toBe(buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken));
});
it("returns null when already on the target remote url", () => {
const nextUrl = buildRemoteDashboardUrl(remoteProfile.serverUrl, remoteProfile.authToken);
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "remote-1",
profiles: [remoteProfile],
},
nextUrl,
),
).toBeNull();
});
it("returns null when there is no matching/active profile", () => {
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: null,
profiles: [remoteProfile],
},
"http://127.0.0.1:50123/",
),
).toBeNull();
expect(
resolveDesktopShellRedirectTarget(
{
host: "desktop-shell",
desktopMode: "remote",
activeProfileId: "missing",
profiles: [remoteProfile],
},
"http://127.0.0.1:50123/",
),
).toBeNull();
});
});

View File

@@ -73,6 +73,76 @@ export function buildRemoteDashboardUrl(serverUrl: string, authToken?: string |
return url.toString();
}
export interface DesktopShellRedirectShellState {
host: "web" | "mobile-shell" | "desktop-shell";
desktopMode?: "local" | "remote";
activeProfileId: string | null;
profiles: Array<{ id: string; serverUrl: string; authToken?: string | null }>;
localRuntime?: {
state: "stopped" | "starting" | "running" | "error";
port?: number;
baseUrl?: string;
};
}
/*
* FNXC:DesktopSwitchServer 2026-07-04-13:20:
* The in-dashboard "Switch server" (Connection Manager) affordance calls `shellApi.setDesktopMode(...)` /
* `setActiveProfile(...)` directly and relies on THIS renderer-side redirect to navigate, because it does not
* route through the Electron main-process launch-mode handlers (`desktopLaunchMode:setMode` in
* packages/desktop/src/main.ts) the way the native menu does. The desktop preload only ever emits the live
* `localRuntime` field (see native-shell.d.ts / packages/desktop/src/ipc.ts) — the legacy `localServer` field is
* never populated and must not be used. This helper is the single decision point shared by both the local- and
* remote-redirect effects in App.tsx so both switch directions behave like the working native-menu path and
* so neither introduces a reload loop.
*/
export function resolveDesktopShellRedirectTarget(
shellState: DesktopShellRedirectShellState,
currentHref: string,
): string | null {
if (shellState.host !== "desktop-shell") {
return null;
}
if (shellState.desktopMode === "local") {
const runtime = shellState.localRuntime;
if (!runtime || runtime.state !== "running") {
return null;
}
const baseUrl = runtime.baseUrl || (runtime.port ? `http://localhost:${runtime.port}` : undefined);
if (!baseUrl) {
return null;
}
if (currentHref === baseUrl) {
return null;
}
try {
const current = new URL(currentHref);
const target = new URL(baseUrl);
if (current.origin === target.origin) {
return null;
}
} catch {
// fall through and navigate — currentHref/baseUrl were not parseable URLs
}
return baseUrl;
}
if (shellState.desktopMode === "remote") {
const activeProfile = shellState.profiles.find((profile) => profile.id === shellState.activeProfileId);
if (!activeProfile) {
return null;
}
const nextUrl = buildRemoteDashboardUrl(activeProfile.serverUrl, activeProfile.authToken ?? null);
if (currentHref === nextUrl) {
return null;
}
return nextUrl;
}
return null;
}
export function requiresNativeShellOnboarding(
shellState: { host: "web" | "mobile-shell" | "desktop-shell"; desktopMode?: "local" | "remote"; activeProfileId: string | null },
shellReady: boolean,