From 934fa71486aa7cbde30d783fcc6235daac5d7d24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Tue, 7 Jul 2026 11:15:03 -0400 Subject: [PATCH] Add a post-launch connectors step to onboarding --- src/renderer/src/app/App.tsx | 12 +- src/renderer/src/app/Onboarding.tsx | 23 +++- .../src/app/OnboardingConnectorsStep.tsx | 84 +++++++++++++ src/renderer/src/pages/ConnectorsPage.tsx | 96 +-------------- .../pages/connectors/useConnectorCatalog.ts | 112 ++++++++++++++++++ tests/e2e/desktop.spec.ts | 5 + tests/e2e/office.spec.ts | 2 + tests/e2e/screenshots.spec.ts | 4 + 8 files changed, 241 insertions(+), 97 deletions(-) create mode 100644 src/renderer/src/app/OnboardingConnectorsStep.tsx create mode 100644 src/renderer/src/pages/connectors/useConnectorCatalog.ts diff --git a/src/renderer/src/app/App.tsx b/src/renderer/src/app/App.tsx index 5ab22f65..1be67c2e 100644 --- a/src/renderer/src/app/App.tsx +++ b/src/renderer/src/app/App.tsx @@ -17,17 +17,25 @@ export function App() { const { snapshot, actions } = useSnapshot(); const [route, setRoute] = useState("home"); const [creatingOrg, setCreatingOrg] = useState(false); + // Keeps the wizard mounted through its post-bootstrap connectors step: the + // organization exists from Launch onward, so the org-count check alone + // would flip to the shell mid-onboarding. + const [finishingSetup, setFinishingSetup] = useState(false); if (!snapshot) { return
; } - if (snapshot.organizations.length === 0 || creatingOrg) { + if (snapshot.organizations.length === 0 || creatingOrg || finishingSetup) { return ( setCreatingOrg(false) : null} - onCompleted={() => setCreatingOrg(false)} + onBootstrapped={() => setFinishingSetup(true)} + onCompleted={() => { + setCreatingOrg(false); + setFinishingSetup(false); + }} /> ); } diff --git a/src/renderer/src/app/Onboarding.tsx b/src/renderer/src/app/Onboarding.tsx index 4649d2b2..facd6e07 100644 --- a/src/renderer/src/app/Onboarding.tsx +++ b/src/renderer/src/app/Onboarding.tsx @@ -7,8 +7,9 @@ import { import { Button, Field, Icon, IconButton, Pill } from "../shared/ui"; import type { SnapshotActions } from "./useSnapshot"; import { ProviderStep } from "./OnboardingProviderStep"; +import { ConnectorsStep } from "./OnboardingConnectorsStep"; -type Step = "welcome" | "provider" | "company" | "mode" | "mission"; +type Step = "welcome" | "provider" | "company" | "mode" | "mission" | "connectors"; export interface Draft { companyName: string; @@ -30,17 +31,20 @@ const STEPS: Array<{ id: Step; label: string }> = [ { id: "company", label: "Company" }, { id: "mode", label: "Mode" }, { id: "mission", label: "Goal" }, + { id: "connectors", label: "Connectors" }, ]; export function Onboarding({ snapshot, actions, onCancel, + onBootstrapped, onCompleted, }: { snapshot: AppSnapshot; actions: SnapshotActions; onCancel: (() => void) | null; + onBootstrapped: () => void; onCompleted: () => void; }) { const availableProviders = useMemo( @@ -66,6 +70,7 @@ export function Onboarding({ }); const [error, setError] = useState(null); const [submitting, setSubmitting] = useState(false); + const [bootstrappedOrgId, setBootstrappedOrgId] = useState(null); const selectedProvider = requireProvider(availableProviders, draft.providerKey); const isMac = window.agentCompany.platform === "darwin"; @@ -117,8 +122,10 @@ export function Onboarding({ setError(result.error.message); return; } + onBootstrapped(); + setBootstrappedOrgId(result.data.orgId); await actions.reload(); - onCompleted(); + setStep("connectors"); }; return ( @@ -138,7 +145,7 @@ export function Onboarding({
A
AgentCompany
- {onCancel && ( + {onCancel && step !== "connectors" && ( @@ -200,6 +207,14 @@ export function Onboarding({ onBack={back("mode")} /> )} + {step === "connectors" && bootstrappedOrgId && ( + + )}
{error &&
{error}
} @@ -220,7 +235,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {

Welcome

- AgentCompany sets up your operating loop in five steps. Bring an API key, pick a runtime + AgentCompany sets up your operating loop in six steps. Bring an API key, pick a runtime for the founding CEO, and we'll wire up the first goal, project, and task.

    diff --git a/src/renderer/src/app/OnboardingConnectorsStep.tsx b/src/renderer/src/app/OnboardingConnectorsStep.tsx new file mode 100644 index 00000000..7bb4ba36 --- /dev/null +++ b/src/renderer/src/app/OnboardingConnectorsStep.tsx @@ -0,0 +1,84 @@ +import { useMemo } from "react"; +import type { AppSnapshot } from "../../../shared/domain"; +import { Button } from "../shared/ui"; +import { ToolkitCatalog } from "../pages/connectors/ToolkitCatalog"; +import { useConnectorCatalog } from "../pages/connectors/useConnectorCatalog"; +import type { SnapshotActions } from "./useSnapshot"; + +/** + * The post-launch onboarding step: the company exists, so connections can be + * made against it. Connecting is optional — Finish enters the app either way + * and the Connectors page carries the same catalog. + */ +export function ConnectorsStep({ + orgId, + snapshot, + actions, + onFinish, +}: { + orgId: string; + snapshot: AppSnapshot; + actions: SnapshotActions; + onFinish: () => void; +}) { + const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions); + const connectedSlugs = useMemo( + () => + new Set( + snapshot.connectorAccounts + .filter((account) => account.orgId === orgId && account.status === "ACTIVE") + .map((account) => account.toolkitSlug), + ), + [snapshot.connectorAccounts, orgId], + ); + + return ( +
    +

    Connectors

    + {catalog.phase === "error" && ( +
    + + {catalog.message} + + +
    + )} + {catalog.phase === "ready" && ( +
    + +
    + )} + {connectError && ( +
    + {connectError} +
    + )} +
    + +
    +
    + ); +} diff --git a/src/renderer/src/pages/ConnectorsPage.tsx b/src/renderer/src/pages/ConnectorsPage.tsx index 46594988..b46660a8 100644 --- a/src/renderer/src/pages/ConnectorsPage.tsx +++ b/src/renderer/src/pages/ConnectorsPage.tsx @@ -1,34 +1,20 @@ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useMemo } from "react"; import { Button } from "../shared/ui"; -import type { ConnectorCatalogEntry } from "../../../shared/commands"; import type { PageProps } from "./types"; import { activeOrgId, requireActiveEmployee } from "./types"; import { ConnectedList } from "./connectors/ConnectedList"; import { ToolkitCatalog } from "./connectors/ToolkitCatalog"; - -type CatalogState = - | { phase: "loading" } - | { phase: "ready"; entries: ConnectorCatalogEntry[] } - | { phase: "error"; message: string }; - -type CatalogSlot = { orgId: string; state: CatalogState } | null; - -const CONNECT_POLL_INTERVAL_MS = 4000; -const CONNECT_POLL_ATTEMPTS = 15; +import { useConnectorCatalog } from "./connectors/useConnectorCatalog"; /** * The company's connector control room: what is connected, who may use it, - * and the Composio catalog to connect more. Connections are org-level; the + * and the configured catalog to connect more. Connections are org-level; the * grant chips decide which agents carry each toolkit's tools. */ export function ConnectorsPage({ snapshot, actions }: PageProps) { const orgId = activeOrgId(snapshot); const employee = requireActiveEmployee(snapshot); - const [catalogSlot, setCatalogSlot] = useState(null); - const [busySlug, setBusySlug] = useState(null); - const [connectError, setConnectError] = useState(null); - const pollTimer = useRef | null>(null); - const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" }; + const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions); const accounts = useMemo( () => snapshot.connectorAccounts.filter((account) => account.orgId === orgId), @@ -46,7 +32,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { () => new Set(accounts.filter((account) => account.status === "ACTIVE").map((account) => account.toolkitSlug)), [accounts], ); - const logoBySlug = useMemo(() => { const map = new Map(); if (catalog.phase === "ready") { @@ -55,67 +40,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { return map; }, [catalog]); - useEffect(() => { - let cancelled = false; - void window.agentCompany.listConnectorCatalog().then((result) => { - if (cancelled) return; - setCatalogSlot({ - orgId, - state: result.ok - ? { phase: "ready", entries: result.data } - : { phase: "error", message: result.error.message }, - }); - }); - return () => { - cancelled = true; - }; - }, [orgId]); - - useEffect(() => () => { - if (pollTimer.current) clearTimeout(pollTimer.current); - }, []); - - // Account statuses sync from Composio when the page opens; a failure here - // is already expressed by the catalog error state. - useEffect(() => { - void window.agentCompany.refreshComposioAccounts({ orgId }); - }, [orgId]); - - const refreshAccounts = () => - actions.run(() => window.agentCompany.refreshComposioAccounts({ orgId })); - - const pollUntilActive = (toolkitSlug: string, attempt: number) => { - if (attempt >= CONNECT_POLL_ATTEMPTS) return; - pollTimer.current = setTimeout(() => { - void refreshAccounts().then((data) => { - const account = data?.connectorAccounts.find( - (item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug, - ); - if (account?.status === "ACTIVE") return; - pollUntilActive(toolkitSlug, attempt + 1); - }); - }, CONNECT_POLL_INTERVAL_MS); - }; - - const connect = (entry: ConnectorCatalogEntry) => { - setBusySlug(entry.slug); - setConnectError(null); - void window.agentCompany - .connectComposioToolkit({ orgId, toolkitSlug: entry.slug }) - .then(async (result) => { - if (!result.ok) { - setConnectError(result.error.message); - return; - } - pollUntilActive(entry.slug, 0); - if (result.data.redirectUrl) { - const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl); - if (!opened.ok) setConnectError(opened.error.message); - } - }) - .finally(() => setBusySlug(null)); - }; - const grant = (agentId: string, toolkitSlug: string) => void actions.run(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id })); @@ -165,17 +89,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) { > {catalog.message} - +
)} {catalog.phase === "ready" && ( diff --git a/src/renderer/src/pages/connectors/useConnectorCatalog.ts b/src/renderer/src/pages/connectors/useConnectorCatalog.ts new file mode 100644 index 00000000..63c70169 --- /dev/null +++ b/src/renderer/src/pages/connectors/useConnectorCatalog.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useRef, useState } from "react"; +import type { ConnectorCatalogEntry } from "../../../../shared/commands"; +import type { SnapshotActions } from "../../app/useSnapshot"; + +export type CatalogState = + | { phase: "loading" } + | { phase: "ready"; entries: ConnectorCatalogEntry[] } + | { phase: "error"; message: string }; + +type CatalogSlot = { orgId: string; state: CatalogState } | null; + +const CONNECT_POLL_INTERVAL_MS = 4000; +const CONNECT_POLL_ATTEMPTS = 15; + +/** + * The connector catalog and connect flow shared by the Connectors page and + * the onboarding step: loads the catalog, starts connections through the + * link flow, and polls account statuses until Composio reports ACTIVE. + */ +export function useConnectorCatalog(orgId: string, actions: SnapshotActions): { + catalog: CatalogState; + busySlug: string | null; + connectError: string | null; + connect: (entry: ConnectorCatalogEntry) => void; + retry: () => void; +} { + const [catalogSlot, setCatalogSlot] = useState(null); + const [busySlug, setBusySlug] = useState(null); + const [connectError, setConnectError] = useState(null); + const pollTimer = useRef | null>(null); + const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" }; + + const load = useCallback(() => { + setCatalogSlot(null); + void window.agentCompany.listConnectorCatalog().then((result) => { + setCatalogSlot({ + orgId, + state: result.ok + ? { phase: "ready", entries: result.data } + : { phase: "error", message: result.error.message }, + }); + }); + }, [orgId]); + + useEffect(() => { + let cancelled = false; + void window.agentCompany.listConnectorCatalog().then((result) => { + if (cancelled) return; + setCatalogSlot({ + orgId, + state: result.ok + ? { phase: "ready", entries: result.data } + : { phase: "error", message: result.error.message }, + }); + }); + return () => { + cancelled = true; + }; + }, [orgId]); + + useEffect(() => () => { + if (pollTimer.current) clearTimeout(pollTimer.current); + }, []); + + // Account statuses sync from Composio on mount; a failure here is already + // expressed by the catalog error state. + useEffect(() => { + void window.agentCompany.refreshComposioAccounts({ orgId }); + }, [orgId]); + + const refreshAccounts = useCallback( + () => actions.run(() => window.agentCompany.refreshComposioAccounts({ orgId })), + [actions, orgId], + ); + + const pollUntilActive = useCallback((toolkitSlug: string) => { + const poll = (attempt: number) => { + if (attempt >= CONNECT_POLL_ATTEMPTS) return; + pollTimer.current = setTimeout(() => { + void refreshAccounts().then((data) => { + const account = data?.connectorAccounts.find( + (item) => item.orgId === orgId && item.toolkitSlug === toolkitSlug, + ); + if (account?.status === "ACTIVE") return; + poll(attempt + 1); + }); + }, CONNECT_POLL_INTERVAL_MS); + }; + poll(0); + }, [refreshAccounts, orgId]); + + const connect = useCallback((entry: ConnectorCatalogEntry) => { + setBusySlug(entry.slug); + setConnectError(null); + void window.agentCompany + .connectComposioToolkit({ orgId, toolkitSlug: entry.slug }) + .then(async (result) => { + if (!result.ok) { + setConnectError(result.error.message); + return; + } + pollUntilActive(entry.slug); + if (result.data.redirectUrl) { + const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl); + if (!opened.ok) setConnectError(opened.error.message); + } + }) + .finally(() => setBusySlug(null)); + }, [orgId, pollUntilActive]); + + return { catalog, busySlug, connectError, connect, retry: load }; +} diff --git a/tests/e2e/desktop.spec.ts b/tests/e2e/desktop.spec.ts index 83680782..19ae0fa1 100644 --- a/tests/e2e/desktop.spec.ts +++ b/tests/e2e/desktop.spec.ts @@ -38,6 +38,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => { await page.getByLabel("Acceptance criteria").fill("The CEO creates the kickoff operating loop."); await page.getByRole("button", { name: "Launch" }).click(); + // The company exists after Launch; the wizard closes with the optional + // connectors step. + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await page.getByRole("button", { name: "Finish" }).click(); + await expect(page.getByRole("button", { name: /Acme Operations/ })).toBeVisible(); await page.getByRole("button", { name: "Work", exact: true }).click(); await expect(page.getByText("Projects", { exact: true })).toBeVisible(); diff --git a/tests/e2e/office.spec.ts b/tests/e2e/office.spec.ts index c7559ab5..fa5d6901 100644 --- a/tests/e2e/office.spec.ts +++ b/tests/e2e/office.spec.ts @@ -34,6 +34,8 @@ test("the office floor opens agent details on click", async () => { await page.getByLabel("Goal").fill("Verify the office floor."); await page.getByLabel("Acceptance criteria").fill("Clicking an agent opens their card."); await page.getByRole("button", { name: "Launch" }).click(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await page.getByRole("button", { name: "Finish" }).click(); await expect(page.getByRole("button", { name: /Pixel Works/ })).toBeVisible(); await page.getByRole("button", { name: "Office", exact: true }).click(); diff --git a/tests/e2e/screenshots.spec.ts b/tests/e2e/screenshots.spec.ts index 8215a1ec..f17db1aa 100644 --- a/tests/e2e/screenshots.spec.ts +++ b/tests/e2e/screenshots.spec.ts @@ -43,6 +43,10 @@ test("captures every page for visual review", async () => { await page.screenshot({ path: join(outDir, "04-onboarding-goal.png"), fullPage: false }); await page.getByRole("button", { name: "Launch" }).click(); + await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible(); + await page.screenshot({ path: join(outDir, "05-onboarding-connectors.png"), fullPage: false }); + await page.getByRole("button", { name: "Finish" }).click(); + await expect(page.getByRole("button", { name: /OrbitOps/ })).toBeVisible(); await snap(page, outDir, "10-home");