From da662c41a79f23fc3e22bee0e70d98db29f71c43 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Fri, 3 Jul 2026 12:54:17 -0700 Subject: [PATCH] fix(onboarding,desktop): don't error on duplicate first agent; wire Switch-server button MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ...oarding-agent-and-desktop-switch-server.md | 7 +++++ .../app/components/ModelOnboardingModal.tsx | 13 ++++++++ .../app/components/SetupWizardModal.tsx | 16 ++++++++++ .../__tests__/SetupWizardModal.test.tsx | 30 +++++++++++++++++++ .../desktop/src/__tests__/preload.test.ts | 28 +++++++++++++++++ packages/desktop/src/preload.ts | 17 +++++++++++ 6 files changed, 111 insertions(+) create mode 100644 .changeset/fix-onboarding-agent-and-desktop-switch-server.md 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);