FN-7478: add local server desktop switch option

Restore the desktop connection manager's local server destination alongside saved remotes.

- Add a Local Server card for desktop-shell switching with active state handling.
- Switch remote profile selection into remote mode before activating the profile.
- Cover desktop local/remote switching behavior with tests and localized labels.
- Document the switch-server local option and add the published package changeset.

Files changed:
 .changeset/fn-7478-local-server-switch-option.md   |  7 +++
 docs/native-shell.md                               |  3 +-
 .../components/NativeShellConnectionManager.css    | 19 ++++++
 .../components/NativeShellConnectionManager.tsx    | 55 ++++++++++++++---
 .../NativeShellConnectionManager.test.tsx          | 71 ++++++++++++++++++++--
 packages/i18n/locales/en/app.json                  |  5 +-
 packages/i18n/locales/es/app.json                  |  5 +-
 packages/i18n/locales/fr/app.json                  |  5 +-
 packages/i18n/locales/ko/app.json                  |  5 +-
 packages/i18n/locales/zh-CN/app.json               |  5 +-
 packages/i18n/locales/zh-TW/app.json               |  5 +-
 packages/i18n/src/resources.d.ts                   |  3 +
 12 files changed, 168 insertions(+), 20 deletions(-)

Fusion-Task-Id: FN-7478

Fusion-Task-Lineage: 86a94faa-4799-46cc-aaed-b7901ee9698c

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-03 15:58:56 -07:00
parent 52dbc0e65e
commit 0900a38630
12 changed files with 168 additions and 20 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Restore Local Server in the desktop Switch server list.
category: fix
dev: Desktop Connection Manager now lists local and saved remote destinations together.

View File

@@ -57,7 +57,8 @@ Profiles are first-class saved objects shared by onboarding and Connection Manag
Connection Manager supports:
- **Use** (activate profile)
- **Desktop Switch server** shows **Local Server** as a selectable destination in the same list as saved remote/server profiles. Selecting **Local Server** calls `setDesktopMode("local")` and returns the shell to the embedded/local Fusion server without deleting remote profiles.
- **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)
- **Delete**
- **Add connection**

View File

@@ -24,6 +24,25 @@
gap: var(--space-md);
}
.native-shell-connection-manager__destination {
width: 100%;
border-color: var(--border);
color: var(--text);
text-align: start;
cursor: pointer;
}
.native-shell-connection-manager__destination:hover,
.native-shell-connection-manager__destination:focus-visible {
border-color: var(--accent-primary);
}
.native-shell-connection-manager__destination > span:first-child {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.native-shell-connection-manager__profile-actions {
display: inline-flex;
gap: var(--space-sm);

View File

@@ -82,6 +82,32 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
resetEditor();
};
const isDesktopShell = shellState.host === "desktop-shell";
const isDesktopLocalActive = isDesktopShell && shellState.desktopMode === "local";
const isProfileActive = (profile: ShellConnectionProfile) => (
shellState.activeProfileId === profile.id && (!isDesktopShell || shellState.desktopMode === "remote")
);
const handleUseLocalServer = async () => {
resetEditor();
setDeleteCandidate(null);
await shellApi.setDesktopMode("local");
};
const handleUseProfile = async (profileId: string) => {
resetEditor();
if (isDesktopShell) {
/*
* FNXC:DesktopSwitchServer 2026-07-03-00:00:
* Desktop Switch server presents local and remote destinations in one list. A remote profile
* selection must explicitly enter remote mode before activation, while the Local Server entry
* is the symmetric return path to the embedded runtime without deleting saved profiles.
*/
await shellApi.setDesktopMode("remote");
}
await shellApi.setActiveProfile(profileId);
};
return (
<div className="modal-overlay open">
<div className="modal native-shell-connection-manager" role="dialog" aria-label={t("shell.connectionManagerLabel", "Connection Manager")}>
@@ -92,14 +118,25 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
</button>
</div>
{shellState.host === "desktop-shell" && (
<div className="native-shell-connection-manager__mode-row">
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>{t("shell.modeLocal", "Local")}</button>
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>{t("shell.modeRemote", "Remote")}</button>
</div>
)}
<div className="native-shell-connection-manager__profiles">
{isDesktopShell && (
<button
type="button"
className="card native-shell-connection-manager__profile native-shell-connection-manager__destination"
onClick={() => void handleUseLocalServer()}
aria-pressed={isDesktopLocalActive}
>
<span>
<strong>{t("shell.localServerTitle", "Local Server")}</strong>
<span className="settings-muted">{t("shell.localServerDescription", "Use the embedded Fusion server on this device.")}</span>
{isDesktopLocalActive && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
</span>
<span className="native-shell-connection-manager__profile-actions" aria-hidden="true">
<span className="btn btn-sm">{isDesktopLocalActive ? t("shell.localServerActive", "Current") : t("shell.use", "Use")}</span>
</span>
</button>
)}
{shellState.profiles.length === 0 ? (
<div className="card native-shell-connection-manager__empty-state">
<p className="settings-muted">{t("shell.noServersSaved", "No remote servers saved yet.")}</p>
@@ -128,7 +165,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
<div>
<strong>{profile.name}</strong>
<div className="settings-muted">{profile.serverUrl}</div>
{profile.id === shellState.activeProfileId && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
{isProfileActive(profile) && <span className="native-shell-connection-manager__active-pill">{t("shell.activePill", "Active")}</span>}
</div>
<div className="native-shell-connection-manager__profile-actions">
<button
@@ -142,7 +179,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
>
{t("actions.edit", "Edit")}
</button>
<button type="button" className="btn btn-sm" aria-label={t("shell.useProfile", "Use {{name}}", { name: profile.name })} onClick={() => void shellApi.setActiveProfile(profile.id)}>{t("shell.use", "Use")}</button>
<button type="button" className="btn btn-sm" aria-label={t("shell.useProfile", "Use {{name}}", { name: profile.name })} onClick={() => void handleUseProfile(profile.id)}>{t("shell.use", "Use")}</button>
<button type="button" className="btn btn-sm btn-danger" aria-label={t("shell.deleteProfile", "Delete {{name}}", { name: profile.name })} onClick={() => setDeleteCandidate(profile)}>{t("actions.delete", "Delete")}</button>
</div>
</div>

View File

@@ -22,20 +22,81 @@ function createShellApi() {
};
}
const remoteProfile = {
id: "remote-1",
name: "Remote",
serverUrl: "https://fusion.example.com",
authToken: "token-value",
createdAt: "",
updatedAt: "",
};
describe("NativeShellConnectionManager", () => {
it("switches desktop mode", async () => {
it("shows a desktop Local Server destination and switches back from an active remote profile", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: null, profiles: [] }}
shellState={{ host: "desktop-shell", desktopMode: "remote", activeProfileId: "remote-1", profiles: [remoteProfile] }}
onClose={vi.fn()}
/>,
);
fireEvent.click(screen.getByText("Local"));
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
expect(screen.getByText("Remote")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /Local Server/i }));
await waitFor(() => expect(shellApi.setDesktopMode).toHaveBeenCalledWith("local"));
expect(shellApi.setActiveProfile).not.toHaveBeenCalled();
});
it("marks Local Server active in desktop local mode and switches remote profile use into remote mode", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{
host: "desktop-shell",
desktopMode: "local",
activeProfileId: "remote-1",
profiles: [
remoteProfile,
{ ...remoteProfile, id: "remote-2", serverUrl: "https://other.example.com" },
],
}}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /Local Server/i })).toHaveAttribute("aria-pressed", "true");
expect(screen.getByText("Active")).toBeInTheDocument();
fireEvent.click(screen.getAllByLabelText("Use Remote")[1]!);
await waitFor(() => {
expect(shellApi.setDesktopMode).toHaveBeenCalledWith("remote");
expect(shellApi.setActiveProfile).toHaveBeenCalledWith("remote-2");
});
expect(shellApi.setDesktopMode.mock.invocationCallOrder[0]).toBeLessThan(shellApi.setActiveProfile.mock.invocationCallOrder[0]);
});
it("renders Local Server deterministically with no remote profiles and an unset desktop mode", () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
open={true}
shellApi={shellApi}
shellState={{ host: "desktop-shell", activeProfileId: null, profiles: [] }}
onClose={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: /Local Server/i })).toBeInTheDocument();
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Add server" })).toBeInTheDocument();
});
it("shows active profile indicator and requires delete confirmation", async () => {
@@ -49,6 +110,7 @@ describe("NativeShellConnectionManager", () => {
/>,
);
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
expect(screen.getByText("Active")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Delete Prod"));
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument();
@@ -79,7 +141,7 @@ describe("NativeShellConnectionManager", () => {
});
});
it("supports empty-state recovery and QR import", async () => {
it("supports empty-state recovery and QR import without a mobile Local Server option", async () => {
const shellApi = createShellApi();
render(
<NativeShellConnectionManager
@@ -90,6 +152,7 @@ describe("NativeShellConnectionManager", () => {
/>,
);
expect(screen.queryByRole("button", { name: /Local Server/i })).toBeNull();
expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
fireEvent.click(screen.getAllByText("Scan QR")[0]!);

View File

@@ -6947,7 +6947,10 @@
"serverUrlLabel": "Server URL",
"serverUrlProtocolError": "Server URL must use http or https",
"use": "Use",
"useProfile": "Use {{name}}"
"useProfile": "Use {{name}}",
"localServerTitle": "Local Server",
"localServerDescription": "Use the embedded Fusion server on this device.",
"localServerActive": "Current"
},
"skills": {
"addSkill": "Add a skill…",

View File

@@ -6937,7 +6937,10 @@
"serverUrlLabel": "URL del servidor",
"serverUrlProtocolError": "La URL del servidor debe usar http o https",
"use": "Usar",
"useProfile": "Usar {{name}}"
"useProfile": "Usar {{name}}",
"localServerTitle": "Servidor local",
"localServerDescription": "Usa el servidor Fusion integrado en este dispositivo.",
"localServerActive": "Actual"
},
"skills": {
"addSkill": "Añadir una habilidad…",

View File

@@ -6937,7 +6937,10 @@
"serverUrlLabel": "URL du serveur",
"serverUrlProtocolError": "L'URL du serveur doit utiliser http ou https",
"use": "Utiliser",
"useProfile": "Utiliser {{name}}"
"useProfile": "Utiliser {{name}}",
"localServerTitle": "Serveur local",
"localServerDescription": "Utiliser le serveur Fusion intégré sur cet appareil.",
"localServerActive": "Actuel"
},
"skills": {
"addSkill": "Ajouter une compétence…",

View File

@@ -6937,7 +6937,10 @@
"serverUrlLabel": "서버 URL",
"serverUrlProtocolError": "서버 URL은 http 또는 https를 사용해야 합니다",
"use": "사용",
"useProfile": "{{name}} 사용"
"useProfile": "{{name}} 사용",
"localServerTitle": "로컬 서버",
"localServerDescription": "이 기기의 내장 Fusion 서버를 사용합니다.",
"localServerActive": "현재"
},
"skills": {
"addSkill": "스킬 추가…",

View File

@@ -6937,7 +6937,10 @@
"serverUrlLabel": "服务器 URL",
"serverUrlProtocolError": "服务器 URL 必须使用 http 或 https",
"use": "使用",
"useProfile": "使用 {{name}}"
"useProfile": "使用 {{name}}",
"localServerTitle": "本地服务器",
"localServerDescription": "使用此设备上的内置 Fusion 服务器。",
"localServerActive": "当前"
},
"skills": {
"addSkill": "添加技能…",

View File

@@ -6937,7 +6937,10 @@
"serverUrlLabel": "伺服器 URL",
"serverUrlProtocolError": "伺服器 URL 必須使用 http 或 https",
"use": "使用",
"useProfile": "使用 {{name}}"
"useProfile": "使用 {{name}}",
"localServerTitle": "本機伺服器",
"localServerDescription": "使用此裝置上的內建 Fusion 伺服器。",
"localServerActive": "目前"
},
"skills": {
"addSkill": "新增技能…",

View File

@@ -6901,6 +6901,9 @@ export default interface Resources {
"deleteConfirmMessage": "Delete {{name}}? This removes the saved profile.",
"deleteProfile": "Delete {{name}}",
"editProfile": "Edit {{name}}",
"localServerActive": "Current",
"localServerDescription": "Use the embedded Fusion server on this device.",
"localServerTitle": "Local Server",
"modeLocal": "Local",
"modeRemote": "Remote",
"nameLabel": "Name",