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:
7
.changeset/fn-7527-desktop-switch-server-navigation.md
Normal file
7
.changeset/fn-7527-desktop-switch-server-navigation.md
Normal 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`.
|
||||
@@ -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)
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -221,7 +221,6 @@ const mockShellConnectionState = {
|
||||
desktopMode: "local" as const,
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
localServer: null,
|
||||
};
|
||||
|
||||
const mockGetShellConnectionNativeResult = vi.fn(async () => ({
|
||||
|
||||
13
packages/dashboard/app/types/native-shell.d.ts
vendored
13
packages/dashboard/app/types/native-shell.d.ts
vendored
@@ -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";
|
||||
|
||||
180
packages/dashboard/app/utils/__tests__/appLifecycle.test.ts
Normal file
180
packages/dashboard/app/utils/__tests__/appLifecycle.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user