Add a post-launch connectors step to onboarding
This commit is contained in:
1 parent
3669b5dfc6
commit
934fa71486
8 files changed
+241
-97
No files matched your search
@@ -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);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user