Connected connectors can be deleted and reconnected; mirror sweeps stale rows and ops serialize per org
This commit is contained in:
1 parent
eb16baff50
commit
38bd3588e7
11 files changed
+187
-33
No files matched your search
@@ -13,7 +13,7 @@ import { useConnectorCatalog } from "./connectors/useConnectorCatalog";
|
||||
*/
|
||||
export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
const { catalog, busySlug, busyAction, connectError, connect, disconnect, retry } = useConnectorCatalog(orgId, actions);
|
||||
|
||||
const accounts = useMemo(
|
||||
() => snapshot.connectorAccounts.filter((account) => account.orgId === orgId),
|
||||
@@ -58,7 +58,16 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectedList accounts={accounts} grants={grants} agents={agents} logoBySlug={logoBySlug} />
|
||||
<ConnectedList
|
||||
accounts={accounts}
|
||||
grants={grants}
|
||||
agents={agents}
|
||||
logoBySlug={logoBySlug}
|
||||
busySlug={busySlug}
|
||||
busyAction={busyAction}
|
||||
onReconnect={connect}
|
||||
onDelete={disconnect}
|
||||
/>
|
||||
|
||||
{catalog.phase === "error" && (
|
||||
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Pill } from "../../shared/ui";
|
||||
import { Button, IconButton, Pill } from "../../shared/ui";
|
||||
import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain";
|
||||
import { ToolkitTile } from "./ToolkitCatalog";
|
||||
|
||||
@@ -15,18 +15,27 @@ const STATUS_TONE: Record<string, "green" | "amber" | "red" | undefined> = {
|
||||
/**
|
||||
* The company's connected toolkits, each with the roster of agents the CEO
|
||||
* has granted access to. Grants are the CEO's call — made with its
|
||||
* grant_connector tool — so the roster here is read-only.
|
||||
* grant_connector tool — so the roster here is read-only. Reconnect restarts
|
||||
* the link flow for the toolkit; delete removes the connection entirely.
|
||||
*/
|
||||
export function ConnectedList({
|
||||
accounts,
|
||||
grants,
|
||||
agents,
|
||||
logoBySlug,
|
||||
busySlug,
|
||||
busyAction,
|
||||
onReconnect,
|
||||
onDelete,
|
||||
}: {
|
||||
accounts: ConnectorAccountRecord[];
|
||||
grants: ConnectorGrantRecord[];
|
||||
agents: AgentRecord[];
|
||||
logoBySlug: Map<string, string | null>;
|
||||
busySlug: string | null;
|
||||
busyAction: "connect" | "disconnect" | null;
|
||||
onReconnect: (toolkitSlug: string) => void;
|
||||
onDelete: (toolkitSlug: string) => void;
|
||||
}) {
|
||||
if (accounts.length === 0) return null;
|
||||
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||
@@ -39,6 +48,7 @@ export function ConnectedList({
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
const busy = busySlug !== null;
|
||||
return (
|
||||
<div key={account.id} style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
@@ -50,6 +60,17 @@ export function ConnectedList({
|
||||
/>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||
<span className="row" style={{ gap: 6, marginLeft: "auto" }}>
|
||||
<Button size="small" disabled={busy} onClick={() => onReconnect(account.toolkitSlug)}>
|
||||
{busySlug === account.toolkitSlug && busyAction === "connect" ? "Opening…" : "Reconnect"}
|
||||
</Button>
|
||||
<IconButton
|
||||
icon="x"
|
||||
label={`Delete ${account.toolkitName} connection`}
|
||||
disabled={busy}
|
||||
onClick={() => onDelete(account.toolkitSlug)}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{holders.length > 0 && (
|
||||
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
|
||||
|
||||
@@ -53,7 +53,7 @@ export function ToolkitCatalog({
|
||||
entries: ConnectorCatalogEntry[];
|
||||
connectedSlugs: Set<string>;
|
||||
busySlug: string | null;
|
||||
onConnect: (entry: ConnectorCatalogEntry) => void;
|
||||
onConnect: (toolkitSlug: string) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
@@ -100,7 +100,7 @@ export function ToolkitCatalog({
|
||||
{connected ? (
|
||||
<Pill tone="green" dot>Connected</Pill>
|
||||
) : (
|
||||
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry)}>
|
||||
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry.slug)}>
|
||||
{busySlug === entry.slug ? "Opening…" : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
@@ -20,12 +20,14 @@ const CONNECT_POLL_ATTEMPTS = 15;
|
||||
export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
catalog: CatalogState;
|
||||
busySlug: string | null;
|
||||
busyAction: "connect" | "disconnect" | null;
|
||||
connectError: string | null;
|
||||
connect: (entry: ConnectorCatalogEntry) => void;
|
||||
connect: (toolkitSlug: string) => void;
|
||||
disconnect: (toolkitSlug: string) => void;
|
||||
retry: () => void;
|
||||
} {
|
||||
const [catalogSlot, setCatalogSlot] = useState<CatalogSlot>(null);
|
||||
const [busySlug, setBusySlug] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<{ slug: string; action: "connect" | "disconnect" } | 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" };
|
||||
@@ -89,24 +91,40 @@ export function useConnectorCatalog(orgId: string, actions: SnapshotActions): {
|
||||
poll(0);
|
||||
}, [refreshAccounts, orgId]);
|
||||
|
||||
const connect = useCallback((entry: ConnectorCatalogEntry) => {
|
||||
setBusySlug(entry.slug);
|
||||
const connect = useCallback((toolkitSlug: string) => {
|
||||
setBusy({ slug: toolkitSlug, action: "connect" });
|
||||
setConnectError(null);
|
||||
void window.agentCompany
|
||||
.connectComposioToolkit({ orgId, toolkitSlug: entry.slug })
|
||||
.connectComposioToolkit({ orgId, toolkitSlug })
|
||||
.then(async (result) => {
|
||||
if (!result.ok) {
|
||||
setConnectError(result.error.message);
|
||||
return;
|
||||
}
|
||||
pollUntilActive(entry.slug);
|
||||
pollUntilActive(toolkitSlug);
|
||||
if (result.data.redirectUrl) {
|
||||
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
|
||||
if (!opened.ok) setConnectError(opened.error.message);
|
||||
}
|
||||
})
|
||||
.finally(() => setBusySlug(null));
|
||||
.finally(() => setBusy(null));
|
||||
}, [orgId, pollUntilActive]);
|
||||
|
||||
return { catalog, busySlug, connectError, connect, retry: load };
|
||||
const disconnect = useCallback((toolkitSlug: string) => {
|
||||
setBusy({ slug: toolkitSlug, action: "disconnect" });
|
||||
setConnectError(null);
|
||||
void actions.run(() => window.agentCompany.disconnectComposioToolkit({ orgId, toolkitSlug }))
|
||||
.catch((error: unknown) => setConnectError(error instanceof Error ? error.message : String(error)))
|
||||
.finally(() => setBusy(null));
|
||||
}, [actions, orgId]);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
busySlug: busy?.slug ?? null,
|
||||
busyAction: busy?.action ?? null,
|
||||
connectError,
|
||||
connect,
|
||||
disconnect,
|
||||
retry: load,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user