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

Two first-run desktop bugs.

## 1. "Agent with this name already exists" when creating the default
CEO
First-time project setup surfaced a **blocking** error when creating the
default **CEO** agent. The default first agent can be created from more
than one first-run surface (the unified `ModelOnboardingModal` agent
step **and** the project-setup `SetupWizardModal` sub-flow), and agent
names are unique per store (`agent-store.ts` `findAgentByName`), so the
second create returns 409.

The step's goal — *a first agent exists* — is 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.

- `ModelOnboardingModal.tsx` `handleCreateFirstAgent`
- `SetupWizardModal.tsx` `handleCreateFirstAgent`

## 2. Header "Desktop local mode / Switch server" button did nothing
Clicking it should open the connection menu to switch between running
locally, connecting to a remote server, or a different remote.

Root cause: main relays the click as a `shell:open-connection-manager`
IPC (`ipc.ts` → `webContents.send`), but the **preload never forwarded
it** to the `window` DOM event `ShellContext.tsx` listens for
(`window.addEventListener("shell:open-connection-manager")`) — so the
signal was dropped and `NativeShellConnectionManager` never opened.

Fix: add the preload bridge (`preload.ts`) that re-dispatches the IPC as
the window event. The modal it opens already provides the Local/Remote
toggle (`setDesktopMode`) plus remote-server profile add/switch —
exactly the requested menu.

## Tests
- `SetupWizardModal.test.tsx`: advances to completion (no error alert)
when `createAgent` rejects with "already exists".
- `preload.test.ts`: the `shell:open-connection-manager` IPC handler
dispatches the `window` event.

## Surfaces enumerated
Both onboarding agent-create surfaces handled (ModelOnboarding +
SetupWizard). The conversational `AgentOnboardingModal` is intentionally
left to surface genuine duplicate errors (it creates user-named agents,
not the first-run default).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Bug Fixes**
* Improved onboarding so if the first agent already exists, setup
continues instead of showing an error.
* Fixed the desktop “Switch-server” button so it reliably opens the
connection manager.
* **Tests**
* Added coverage for the onboarding flow when the agent already exists.
* Added coverage to verify the desktop connection-manager event is
forwarded correctly.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-03 13:06:27 -07:00
committed by GitHub
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);
handleNext();
} 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"));
} finally {
setIsCreatingAgent(false);

View File

@@ -308,6 +308,22 @@ export function SetupWizardModal({
agentOutcome: "created",
}));
} 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) => ({
...prev,
isCreatingAgent: false,

View File

@@ -721,6 +721,36 @@ describe("SetupWizardModal", () => {
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 () => {
const mockProject = buildMockProject();
mockRegisterProject.mockResolvedValueOnce(mockProject);

View File

@@ -70,6 +70,34 @@ describe("preload", () => {
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 () => {
await importPreloadModule();
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("fusionAPI", electronApi);
contextBridge.exposeInMainWorld("fusionShell", fusionShell);