fix(onboarding,desktop): don't error on duplicate first agent; wire Switch-server button

First-run project setup surfaced a blocking "Agent with this name already exists" error when creating
the default CEO. The default first agent can be created from more than one first-run surface (the
unified ModelOnboarding agent step and the project-setup SetupWizard sub-flow), and agent names are
unique per store, so the second create returned 409. The step's goal — a first agent exists — was
already satisfied, so both onboarding surfaces now treat a name collision as success and advance
instead of blocking. The user still creates the agent; they just aren't punished for the flow offering
it twice.

Separately, the desktop header "Desktop local mode / Switch server" button did nothing: main relays
the click as a `shell:open-connection-manager` IPC (webContents.send), but the preload never forwarded
it to the `window` DOM event ShellContext listens for, so the signal was dropped. Add the preload
bridge so the button opens NativeShellConnectionManager (Local/Remote toggle + remote-server profiles),
letting operators switch between running locally, connecting to a remote server, or a different remote.

Regression tests: SetupWizard advances on an already-exists agent; preload dispatches the window event
on the IPC.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-07-03 12:54:17 -07:00
parent 236bad7b6e
commit da662c41a7
6 changed files with 111 additions and 0 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: First-run agent setup no longer errors on a duplicate CEO; desktop Switch-server button now opens the connection menu.
category: fix
dev: Onboarding agent creation (ModelOnboardingModal + SetupWizardModal) treats a 409 "Agent with this name already exists" as success and advances, since the default CEO can be created from more than one first-run surface. The desktop preload now bridges the `shell:open-connection-manager` IPC (sent by main when the header Switch-server button is clicked) into the `window` DOM event ShellContext listens for, so NativeShellConnectionManager (Local/Remote toggle + remote profiles) actually opens.

View File

@@ -1201,6 +1201,19 @@ export function ModelOnboardingModal({
await createAgent(buildAgentCreatePayload(agentDraft), targetProjectId); await createAgent(buildAgentCreatePayload(agentDraft), targetProjectId);
handleNext(); handleNext();
} catch (err) { } catch (err) {
/*
* FNXC:Onboarding 2026-07-03-12:10:
* The default first agent ("CEO") can already exist by the time this step runs — the operator can
* reach agent creation from more than one first-run surface (the project-setup SetupWizard sub-flow
* also offers to create the first agent), and agent names are unique per store, so a redundant
* create returns 409 "Agent with this name already exists". The step's goal — a first agent exists —
* is already satisfied, so treat a name collision as success and advance instead of blocking first
* run. The user still creates the agent; they just aren't punished for the flow offering it twice.
*/
if (err instanceof Error && /already exists/i.test(err.message)) {
handleNext();
return;
}
setAgentCreationError(err instanceof Error ? err.message : t("setup.firstAgentCreateError", "Failed to create agent")); setAgentCreationError(err instanceof Error ? err.message : t("setup.firstAgentCreateError", "Failed to create agent"));
} finally { } finally {
setIsCreatingAgent(false); setIsCreatingAgent(false);

View File

@@ -308,6 +308,22 @@ export function SetupWizardModal({
agentOutcome: "created", agentOutcome: "created",
})); }));
} catch (err) { } catch (err) {
/*
* FNXC:Onboarding 2026-07-03-12:10:
* A same-named first agent ("CEO") may already exist — created via another first-run surface
* (the unified ModelOnboarding agent step) or a prior attempt; agent names are unique per store.
* The desired end state (a first agent exists) already holds, so treat a name collision as success
* and complete setup rather than blocking with "Agent with this name already exists".
*/
if (err instanceof Error && /already exists/i.test(err.message)) {
setState((prev) => ({
...prev,
step: "complete",
isCreatingAgent: false,
agentOutcome: "created",
}));
return;
}
setState((prev) => ({ setState((prev) => ({
...prev, ...prev,
isCreatingAgent: false, isCreatingAgent: false,

View File

@@ -721,6 +721,36 @@ describe("SetupWizardModal", () => {
expect(await screen.findByText("You can create agents later from the Agents view.")).toBeDefined(); expect(await screen.findByText("You can create agents later from the Agents view.")).toBeDefined();
}); });
it("treats an already-exists agent as success instead of blocking setup", async () => {
// FNXC:Onboarding 2026-07-03: the default "CEO" can already exist when this step runs — reached via
// the unified ModelOnboarding agent step or a prior attempt; agent names are unique per store, so a
// redundant create returns 409 "already exists". That must NOT block first-run: the desired end
// state (a first agent exists) already holds, so the wizard advances to completion.
const mockProject = buildMockProject();
const onProjectRegistered = vi.fn();
mockRegisterProject.mockResolvedValueOnce(mockProject);
mockCreateAgent.mockRejectedValueOnce(
new Error('Agent with name "CEO" already exists (agentId: agent_ceo)'),
);
render(
<SetupWizardModal
onProjectRegistered={onProjectRegistered}
onClose={vi.fn()}
/>
);
expect(await registerProjectFromWizard()).toBeDefined();
fireEvent.click(screen.getByText("Create Agent"));
// Advances to the completion step; no blocking error alert.
expect(await screen.findByText("Your project is registered and your first agent is ready.")).toBeDefined();
expect(screen.queryByRole("alert")).toBeNull();
fireEvent.click(screen.getByText("Get Started"));
expect(onProjectRegistered).toHaveBeenCalledWith(mockProject);
});
it("applies an AI interview draft and waits for explicit creation", async () => { it("applies an AI interview draft and waits for explicit creation", async () => {
const mockProject = buildMockProject(); const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject); mockRegisterProject.mockResolvedValueOnce(mockProject);

View File

@@ -70,6 +70,34 @@ describe("preload", () => {
expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager"); expect(mocks.ipcRenderer.invoke).toHaveBeenCalledWith("shell:openConnectionManager");
}); });
it("bridges the shell:open-connection-manager IPC into a window DOM event", async () => {
// Regression: main sends `shell:open-connection-manager` via webContents.send when the header
// "Switch server" button is clicked; the renderer's ShellContext listens via
// window.addEventListener. Without the preload forwarding it, clicking did nothing.
await importPreloadModule();
const bridge = mocks.ipcRenderer.on.mock.calls.find(
([channel]) => channel === "shell:open-connection-manager",
)?.[1] as (() => void) | undefined;
expect(bridge).toBeTruthy();
const dispatched: string[] = [];
const priorWindow = (globalThis as { window?: unknown }).window;
(globalThis as { window?: unknown }).window = {
dispatchEvent: (event: Event) => {
dispatched.push(event.type);
return true;
},
};
try {
bridge?.();
} finally {
(globalThis as { window?: unknown }).window = priorWindow;
}
expect(dispatched).toContain("shell:open-connection-manager");
});
it("electronAPI exposes update-not-available and update-error listeners", async () => { it("electronAPI exposes update-not-available and update-error listeners", async () => {
await importPreloadModule(); await importPreloadModule();
const api = getExposed<{ const api = getExposed<{

View File

@@ -139,6 +139,23 @@ const fusionShell = {
}, },
}; };
/*
FNXC:DesktopShell 2026-07-03-12:10:
Bridge the main-process "open connection manager" request into a window DOM event. The header
"Switch server" / "Manage connections" button (ShellConnectionStatus) calls
`fusionShell.openConnectionManager()`, which invokes the `shell:openConnectionManager` IPC; main then
round-trips back via `webContents.send("shell:open-connection-manager")` (ipc.ts). The renderer's
ShellContext listens with `window.addEventListener("shell:open-connection-manager")` to raise
`openConnectionManagerSignal` and open NativeShellConnectionManager. Without this forwarder the IPC
event was dropped, so clicking "Switch server" in Desktop local mode did nothing. DOM events dispatched
on the shared window cross the isolated-preload → page-world boundary, so the page listener fires.
Re-registered on every navigation (including the handoff to the http runtime origin) because the
preload re-runs per page load.
*/
ipcRenderer.on("shell:open-connection-manager", () => {
window.dispatchEvent(new Event("shell:open-connection-manager"));
});
contextBridge.exposeInMainWorld("electronAPI", electronApi); contextBridge.exposeInMainWorld("electronAPI", electronApi);
contextBridge.exposeInMainWorld("fusionAPI", electronApi); contextBridge.exposeInMainWorld("fusionAPI", electronApi);
contextBridge.exposeInMainWorld("fusionShell", fusionShell); contextBridge.exposeInMainWorld("fusionShell", fusionShell);