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: 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) - **Edit** (update name/URL/token)
- **Delete** - **Delete**
- **Add connection** - **Add connection**

View File

@@ -24,6 +24,25 @@
gap: var(--space-md); 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 { .native-shell-connection-manager__profile-actions {
display: inline-flex; display: inline-flex;
gap: var(--space-sm); gap: var(--space-sm);

View File

@@ -82,6 +82,32 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
resetEditor(); 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 ( return (
<div className="modal-overlay open"> <div className="modal-overlay open">
<div className="modal native-shell-connection-manager" role="dialog" aria-label={t("shell.connectionManagerLabel", "Connection Manager")}> <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> </button>
</div> </div>
{shellState.host === "desktop-shell" && ( <div className="native-shell-connection-manager__profiles">
<div className="native-shell-connection-manager__mode-row"> {isDesktopShell && (
<button type="button" className={`btn ${shellState.desktopMode === "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("local")}>{t("shell.modeLocal", "Local")}</button> <button
<button type="button" className={`btn ${shellState.desktopMode !== "local" ? "btn-primary" : ""}`} onClick={() => void shellApi.setDesktopMode("remote")}>{t("shell.modeRemote", "Remote")}</button> type="button"
</div> 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>
)} )}
<div className="native-shell-connection-manager__profiles">
{shellState.profiles.length === 0 ? ( {shellState.profiles.length === 0 ? (
<div className="card native-shell-connection-manager__empty-state"> <div className="card native-shell-connection-manager__empty-state">
<p className="settings-muted">{t("shell.noServersSaved", "No remote servers saved yet.")}</p> <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> <div>
<strong>{profile.name}</strong> <strong>{profile.name}</strong>
<div className="settings-muted">{profile.serverUrl}</div> <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>
<div className="native-shell-connection-manager__profile-actions"> <div className="native-shell-connection-manager__profile-actions">
<button <button
@@ -142,7 +179,7 @@ export function NativeShellConnectionManager({ open, shellApi, shellState, onClo
> >
{t("actions.edit", "Edit")} {t("actions.edit", "Edit")}
</button> </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> <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>
</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", () => { 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(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <NativeShellConnectionManager
open={true} open={true}
shellApi={shellApi} 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()} 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")); 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 () => { 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(); expect(screen.getByText("Active")).toBeInTheDocument();
fireEvent.click(screen.getByLabelText("Delete Prod")); fireEvent.click(screen.getByLabelText("Delete Prod"));
expect(screen.getByRole("alertdialog", { name: "Delete server confirmation" })).toBeInTheDocument(); 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(); const shellApi = createShellApi();
render( render(
<NativeShellConnectionManager <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(); expect(screen.getByText("No remote servers saved yet.")).toBeInTheDocument();
fireEvent.click(screen.getAllByText("Scan QR")[0]!); fireEvent.click(screen.getAllByText("Scan QR")[0]!);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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