diff --git a/.changeset/fix-onboarding-agent-and-desktop-switch-server.md b/.changeset/fix-onboarding-agent-and-desktop-switch-server.md new file mode 100644 index 0000000000..58079ad28c --- /dev/null +++ b/.changeset/fix-onboarding-agent-and-desktop-switch-server.md @@ -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. diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 28525e69df..53756029d7 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -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); diff --git a/packages/dashboard/app/components/SetupWizardModal.tsx b/packages/dashboard/app/components/SetupWizardModal.tsx index 071e41e604..1625dc3c23 100644 --- a/packages/dashboard/app/components/SetupWizardModal.tsx +++ b/packages/dashboard/app/components/SetupWizardModal.tsx @@ -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, diff --git a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx b/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx index 4912ee063b..b1c3e20a8c 100644 --- a/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/SetupWizardModal.test.tsx @@ -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( + + ); + + 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); diff --git a/packages/desktop/src/__tests__/preload.test.ts b/packages/desktop/src/__tests__/preload.test.ts index 856961b121..1c696ebb0f 100644 --- a/packages/desktop/src/__tests__/preload.test.ts +++ b/packages/desktop/src/__tests__/preload.test.ts @@ -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<{ diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 4c126a619c..a2d7362560 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -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);