Add a post-launch connectors step to onboarding

This commit is contained in:
ZacharyZhang-NY committed 2026-07-07 11:15:03 -04:00
1 parent 3669b5dfc6
commit 934fa71486
8 files changed
+241 -97

No files matched your search

+10 -2
View File
@@ -17,17 +17,25 @@ export function App() {
const { snapshot, actions } = useSnapshot();
const [route, setRoute] = useState<RouteId>("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 <div className="app-shell" style={{ display: "grid", placeItems: "center" }} />;
}
if (snapshot.organizations.length === 0 || creatingOrg) {
if (snapshot.organizations.length === 0 || creatingOrg || finishingSetup) {
return (
<Onboarding
snapshot={snapshot}
actions={actions}
onCancel={creatingOrg ? () => setCreatingOrg(false) : null}
onCompleted={() => setCreatingOrg(false)}
onBootstrapped={() => setFinishingSetup(true)}
onCompleted={() => {
setCreatingOrg(false);
setFinishingSetup(false);
}}
/>
);
}
+19 -4
View File
@@ -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<string | null>(null);
const [submitting, setSubmitting] = useState(false);
const [bootstrappedOrgId, setBootstrappedOrgId] = useState<string | null>(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({
<div className="row" style={{ gap: 8 }}>
<div className="brand-mark">A</div>
<div style={{ fontWeight: 540 }}>AgentCompany</div>
{onCancel && (
{onCancel && step !== "connectors" && (
<button type="button" className="button ghost" style={{ marginLeft: "auto" }} onClick={onCancel}>
Cancel
</button>
@@ -200,6 +207,14 @@ export function Onboarding({
onBack={back("mode")}
/>
)}
{step === "connectors" && bootstrappedOrgId && (
<ConnectorsStep
orgId={bootstrappedOrgId}
snapshot={snapshot}
actions={actions}
onFinish={onCompleted}
/>
)}
</div>
{error && <div style={{ color: "var(--red)", marginTop: 16, fontSize: 14 }}>{error}</div>}
</form>
@@ -220,7 +235,7 @@ function WelcomeStep({ onNext }: { onNext: () => void }) {
<div className="form-grid">
<h2 className="page-h1">Welcome</h2>
<p style={{ color: "var(--text-2)", fontSize: 14, lineHeight: 1.55 }}>
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.
</p>
<ul style={{ paddingLeft: 20, color: "var(--text-2)", lineHeight: 1.8, margin: 0, fontSize: 14 }}>
@@ -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 (
<div className="form-grid">
<h2 className="page-h1">Connectors</h2>
{catalog.phase === "error" && (
<div className="row" style={{ gap: 10 }}>
<span
style={{
color: "var(--text-2)",
fontSize: 13,
flex: 1,
minWidth: 0,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{catalog.message}
</span>
<Button size="small" onClick={retry}>Retry</Button>
</div>
)}
{catalog.phase === "ready" && (
<div style={{ maxHeight: 420, overflowY: "auto", paddingRight: 4 }}>
<ToolkitCatalog
entries={catalog.entries}
connectedSlugs={connectedSlugs}
busySlug={busySlug}
onConnect={connect}
/>
</div>
)}
{connectError && (
<div
style={{
color: "var(--red)",
fontSize: 13,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{connectError}
</div>
)}
<div className="modal-actions" style={{ marginTop: 4 }}>
<Button tone="primary" onClick={onFinish}>Finish</Button>
</div>
</div>
);
}
+5 -91
View File
@@ -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<CatalogSlot>(null);
const [busySlug, setBusySlug] = useState<string | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const pollTimer = useRef<ReturnType<typeof setTimeout> | 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<string, string | null>();
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}
</span>
<Button size="small" onClick={() => {
setCatalogSlot(null);
void window.agentCompany.listConnectorCatalog().then((result) => {
setCatalogSlot({
orgId,
state: result.ok
? { phase: "ready", entries: result.data }
: { phase: "error", message: result.error.message },
});
});
}}>Retry</Button>
<Button size="small" onClick={retry}>Retry</Button>
</div>
)}
{catalog.phase === "ready" && (
@@ -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<CatalogSlot>(null);
const [busySlug, setBusySlug] = useState<string | null>(null);
const [connectError, setConnectError] = useState<string | null>(null);
const pollTimer = useRef<ReturnType<typeof setTimeout> | 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 };
}
+5
View File
@@ -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();
+2
View File
@@ -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();
+4
View File
@@ -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");