Replace Social with a Connectors control room

The Social surface gives way to Connectors: the searchable Composio catalog
(letter-glyph tiles - the CSP stays closed to remote images), one-click
connect that opens the authorization link in the browser and polls the
account until it turns active, and a Connected section where each toolkit
shows its status and a chip per agent that grants or revokes their access
on the spot. Route, nav label, glyph, and search targets follow the rename;
the social backend services remain for the domain while their page and e2e
steps retire in favor of connector coverage.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-06 23:24:01 -04:00
1 parent fb72f1f29b
commit e9cd22e7f4
10 files changed
+399 -364

No files matched your search

@@ -8,7 +8,7 @@ import { WorkPage } from "../pages/WorkPage";
import { ChatPage } from "../pages/ChatPage";
import { FilesPage } from "../pages/FilesPage";
import { ApprovalsPage } from "../pages/ApprovalsPage";
import { SocialPage } from "../pages/SocialPage";
import { ConnectorsPage } from "../pages/ConnectorsPage";
import { DocumentsPage } from "../pages/DocumentsPage";
import { RunsPage } from "../pages/RunsPage";
import { SettingsPage } from "../pages/SettingsPage";
@@ -43,7 +43,7 @@ export function App() {
docs: <DocumentsPage {...pageProps} />,
runs: <RunsPage {...pageProps} />,
approvals: <ApprovalsPage {...pageProps} />,
social: <SocialPage {...pageProps} />,
connectors: <ConnectorsPage {...pageProps} />,
office: <OfficePage {...pageProps} />,
settings: <SettingsPage {...pageProps} />,
}[route];
@@ -0,0 +1,150 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { Button } from "../shared/ui";
import type { ComposioToolkitSummaryDto } 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"; toolkits: ComposioToolkitSummaryDto[] }
| { phase: "error"; message: string };
type CatalogSlot = { orgId: string; state: CatalogState } | null;
const CONNECT_POLL_INTERVAL_MS = 4000;
const CONNECT_POLL_ATTEMPTS = 15;
/**
* 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
* 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 pollTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const catalog: CatalogState = catalogSlot && catalogSlot.orgId === orgId ? catalogSlot.state : { phase: "loading" };
const accounts = useMemo(
() => snapshot.connectorAccounts.filter((account) => account.orgId === orgId),
[snapshot.connectorAccounts, orgId],
);
const grants = useMemo(
() => snapshot.connectorGrants.filter((grant) => grant.orgId === orgId),
[snapshot.connectorGrants, orgId],
);
const agents = useMemo(
() => snapshot.agents.filter((agent) => agent.orgId === orgId && agent.lifecycle === "active"),
[snapshot.agents, orgId],
);
const connectedSlugs = useMemo(
() => new Set(accounts.filter((account) => account.status === "ACTIVE").map((account) => account.toolkitSlug)),
[accounts],
);
useEffect(() => {
let cancelled = false;
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
if (cancelled) return;
setCatalogSlot({
orgId,
state: result.ok
? { phase: "ready", toolkits: result.data }
: { phase: "error", message: result.error.message },
});
});
return () => {
cancelled = true;
};
}, [orgId]);
useEffect(() => () => {
if (pollTimer.current) clearTimeout(pollTimer.current);
}, []);
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 = (toolkit: ComposioToolkitSummaryDto) => {
setBusySlug(toolkit.slug);
void window.agentCompany
.connectComposioToolkit({ orgId, toolkitSlug: toolkit.slug, toolkitName: toolkit.name })
.then((result) => {
if (!result.ok) throw new Error(result.error.message);
if (result.data.redirectUrl) {
void window.agentCompany.openExternalUrl(result.data.redirectUrl);
}
pollUntilActive(toolkit.slug, 0);
})
.finally(() => setBusySlug(null));
};
const grant = (agentId: string, toolkitSlug: string) =>
void actions.run(() => window.agentCompany.grantConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id }));
const revoke = (agentId: string, toolkitSlug: string) =>
void actions.run(() => window.agentCompany.revokeConnector({ orgId, agentId, toolkitSlug, employeeId: employee.id }));
return (
<div style={{ maxWidth: 1060 }}>
<div className="row" style={{ marginBottom: 18 }}>
<div>
<h2 style={{ margin: 0, fontSize: 16, fontWeight: 600 }}>Connectors</h2>
<div style={{ fontSize: 12.5, color: "var(--text-3)", marginTop: 2 }}>
Company connections your agents can be granted to use.
</div>
</div>
<div style={{ marginLeft: "auto" }}>
<Button size="small" onClick={() => void refreshAccounts()}>Refresh</Button>
</div>
</div>
<ConnectedList accounts={accounts} grants={grants} agents={agents} onGrant={grant} onRevoke={revoke} />
{catalog.phase === "loading" && (
<div style={{ color: "var(--text-3)", fontSize: 13, padding: "24px 0" }}>Loading catalog…</div>
)}
{catalog.phase === "error" && (
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
<span style={{ color: "var(--text-2)", fontSize: 13 }}>{catalog.message}</span>
<Button size="small" onClick={() => {
setCatalogSlot(null);
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
setCatalogSlot({
orgId,
state: result.ok
? { phase: "ready", toolkits: result.data }
: { phase: "error", message: result.error.message },
});
});
}}>Retry</Button>
</div>
)}
{catalog.phase === "ready" && (
<ToolkitCatalog
toolkits={catalog.toolkits}
connectedSlugs={connectedSlugs}
busySlug={busySlug}
onConnect={connect}
/>
)}
</div>
);
}
@@ -1,332 +0,0 @@
import { useMemo, useState, type FormEvent } from "react";
import { Avatar, Button, Field, Icon, Pill } from "../shared/ui";
import { TopbarActions } from "../shared/TopbarActions";
import { Dialog } from "../shared/Dialog";
import {
SOCIAL_PLATFORM,
type EmployeeRecord,
type ApprovalState,
type ScheduledPostRecord,
type SocialAccountRecord,
type SocialPlatform,
} from "../../../shared/domain";
import { APPROVAL_STATE } from "../../../shared/domain";
import { SocialCalendar } from "./social/SocialCalendar";
import { SocialActivity } from "./social/SocialActivity";
import { PlatformIcon } from "./social/PlatformIcon";
import type { PageProps } from "./types";
import { activeOrgId, requireActiveEmployee } from "./types";
function value(form: FormData, key: string): string {
return String(form.get(key) ?? "").trim();
}
function optional(form: FormData, key: string): string | null {
return value(form, key) || null;
}
export function SocialPage({ snapshot, actions }: PageProps) {
const orgId = activeOrgId(snapshot);
const employee = requireActiveEmployee(snapshot);
const accounts = useMemo(() => snapshot.socialAccounts.filter((account) => account.orgId === orgId), [snapshot.socialAccounts, orgId]);
const scheduledPosts = useMemo(() => snapshot.scheduledPosts.filter((post) => post.orgId === orgId), [snapshot.scheduledPosts, orgId]);
const approvalStateById = useMemo(() => {
const states = new Map<string, ApprovalState>();
for (const approval of snapshot.approvals) {
if (approval.orgId === orgId) states.set(approval.id, approval.state);
}
return states;
}, [snapshot.approvals, orgId]);
const employees = snapshot.employees.filter((item) => item.orgId === orgId);
const socialGrants = useMemo(() => snapshot.socialAccountGrants.filter((grant) => grant.orgId === orgId), [snapshot.socialAccountGrants, orgId]);
const socialAudit = useMemo(() => snapshot.socialAuditRecords.filter((record) => record.orgId === orgId), [snapshot.socialAuditRecords, orgId]);
const browserActions = useMemo(() => snapshot.browserActions.filter((action) => action.orgId === orgId), [snapshot.browserActions, orgId]);
const [connectOpen, setConnectOpen] = useState(false);
const [draftOpen, setDraftOpen] = useState(false);
const scheduled = scheduledPosts.filter((post) => post.state === "scheduled");
const drafts = scheduledPosts.filter((post) => post.state === "draft");
const history = scheduledPosts.filter((post) => post.state === "published" || post.state === "cancelled");
const operator = accounts.find((account) => account.authorizedEmployeeId)?.authorizedEmployeeId ?? null;
const operatorName = operator ? employees.find((item) => item.id === operator)?.displayName ?? null : null;
const connect = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createSocialAccount({
orgId,
platform: value(form, "platform") as SocialPlatform,
handle: value(form, "handle"),
displayName: value(form, "displayName"),
createdByEmployeeId: employee.id,
authorizedEmployeeId: optional(form, "authorizedEmployeeId"),
})).then((result) => { if (result) setConnectOpen(false); });
};
const draft = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
void actions.run(() => window.agentCompany.createScheduledPost({
orgId,
socialAccountId: value(form, "socialAccountId"),
bodyMd: value(form, "bodyMd"),
scheduledFor: value(form, "scheduledFor"),
createdByEmployeeId: employee.id,
})).then((result) => { if (result) setDraftOpen(false); });
};
const updatePostState = (post: ScheduledPostRecord, state: "draft" | "scheduled" | "published" | "cancelled") =>
void actions.run(() => window.agentCompany.setScheduledPostState({ orgId, scheduledPostId: post.id, state, employeeId: employee.id }));
return (
<div className="page" style={{ display: "flex", flexDirection: "column", minHeight: 0, height: "100%", padding: "28px 32px 0" }}>
<TopbarActions>
<Button onClick={() => setConnectOpen(true)}><Icon name="plus" size={16} /> Connect account</Button>
<Button tone="primary" onClick={() => setDraftOpen(true)} disabled={accounts.length === 0}>
<Icon name="sparkle" size={16} /> Draft post
</Button>
</TopbarActions>
<h1 className="page-h1">Social</h1>
<p className="page-sub">
{accounts.length} {accounts.length === 1 ? "account" : "accounts"} · {scheduled.length} scheduled
{operatorName ? ` · managed by ${operatorName}` : ""}
</p>
<div className="pane-scroll" style={{ paddingBottom: 40 }}>
{accounts.length > 0 && (
<>
<div className="section-label" style={{ marginBottom: 10 }}>Accounts</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(220px, 1fr))", gap: 10, marginBottom: 28 }}>
{accounts.map((account) => (
<AccountCard
key={account.id}
account={account}
authorizedName={account.authorizedEmployeeId ? employees.find((item) => item.id === account.authorizedEmployeeId)?.displayName ?? null : null}
canSignIn={account.authorizedEmployeeId === employee.id}
onSignIn={() => void actions.run(() => window.agentCompany.launchSocialLogin({ orgId, socialAccountId: account.id, employeeId: employee.id }))}
/>
))}
</div>
</>
)}
<div style={{ display: "grid", gridTemplateColumns: "minmax(0, 1.4fr) minmax(0, 1fr)", gap: 12, alignItems: "start" }}>
<div>
{scheduled.length > 0 && <div className="section-label" style={{ marginBottom: 10 }}>Scheduled</div>}
{scheduled.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "published")}
/>
))}
{drafts.length > 0 && <div className="section-label" style={{ margin: "24px 0 10px" }}>Drafts</div>}
{drafts.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "scheduled")}
/>
))}
{history.length > 0 && <div className="section-label" style={{ margin: "24px 0 10px" }}>History</div>}
{history.map((post) => (
<PostRow
key={post.id}
post={post}
account={requireAccount(accounts, post.socialAccountId)}
authorName={authorName(post, employees)}
approvalState={approvalState(post, approvalStateById)}
onCancel={() => updatePostState(post, "cancelled")}
onPublish={() => updatePostState(post, "published")}
/>
))}
</div>
<SocialCalendar posts={scheduledPosts} accounts={accounts} />
</div>
<SocialActivity
accounts={accounts}
grants={socialGrants}
auditRecords={socialAudit}
browserActions={browserActions}
employees={employees}
/>
</div>
<Dialog title="Connect social account" open={connectOpen} submitLabel="Connect" onClose={() => setConnectOpen(false)} onSubmit={connect}>
<Field label="Platform">
<select className="input" name="platform" defaultValue={SOCIAL_PLATFORM.X}>
<option value={SOCIAL_PLATFORM.X}>X</option>
<option value={SOCIAL_PLATFORM.LINKEDIN}>LinkedIn</option>
<option value={SOCIAL_PLATFORM.YOUTUBE}>YouTube</option>
<option value={SOCIAL_PLATFORM.INSTAGRAM}>Instagram</option>
</select>
</Field>
<Field label="Handle"><input className="input" name="handle" required /></Field>
<Field label="Display name"><input className="input" name="displayName" required /></Field>
<Field label="Authorized employee">
<select className="input" name="authorizedEmployeeId" defaultValue={employee.id}>
<option value="">Unassigned</option>
{employees.map((item) => <option key={item.id} value={item.id}>{item.displayName}</option>)}
</select>
</Field>
</Dialog>
<Dialog title="Draft post" open={draftOpen} submitLabel="Save" onClose={() => setDraftOpen(false)} onSubmit={draft}>
<Field label="Account">
<select className="input" name="socialAccountId" required defaultValue={accounts[0]?.id ?? ""}>
{accounts.map((account) => (
<option key={account.id} value={account.id}>{account.platform.toUpperCase()} · {account.handle}</option>
))}
</select>
</Field>
<Field label="Body"><textarea className="input" name="bodyMd" required /></Field>
<Field label="Scheduled for"><input className="input" name="scheduledFor" type="datetime-local" required /></Field>
</Dialog>
</div>
);
}
function AccountCard({
account,
authorizedName,
canSignIn,
onSignIn,
}: {
account: SocialAccountRecord;
authorizedName: string | null;
canSignIn: boolean;
onSignIn: () => void;
}) {
return (
<div
style={{
border: "1px solid var(--border)",
borderRadius: 10,
padding: 14,
background: "var(--panel)",
display: "flex",
flexDirection: "column",
gap: 10,
minWidth: 0,
}}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<PlatformIcon platform={account.platform} />
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontSize: 14, fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.handle}</div>
<div style={{ fontSize: 12, color: "var(--text-3)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{account.displayName}</div>
</div>
</div>
{authorizedName && (
<div className="row" style={{ fontSize: 12, color: "var(--text-3)", gap: 6 }}>
<span style={{ width: 6, height: 6, borderRadius: 3, background: "var(--green)" }} /> Authorized to {authorizedName}
</div>
)}
{canSignIn && (
<Button size="small" onClick={onSignIn}>Sign in</Button>
)}
</div>
);
}
function PostRow({
post,
account,
authorName,
approvalState,
onCancel,
onPublish,
}: {
post: ScheduledPostRecord;
account: SocialAccountRecord;
authorName: string;
approvalState: ApprovalState | null;
onCancel: () => void;
onPublish: () => void;
}) {
const isDraft = post.state === "draft";
const canPublish = post.state === "scheduled" && approvalState === APPROVAL_STATE.APPROVED;
return (
<div
style={{
display: "grid",
gridTemplateColumns: "36px minmax(0, 1fr) auto",
gap: 14,
padding: 14,
border: "1px solid var(--border)",
borderRadius: 10,
background: "var(--panel)",
marginBottom: 8,
}}
>
<PlatformIcon platform={account.platform} size={18} />
<div style={{ minWidth: 0 }}>
<div className="row" style={{ gap: 8, marginBottom: 6 }}>
{isDraft ? (
<Pill>Draft</Pill>
) : (
<span className="num mono" style={{ fontSize: 12, color: "var(--text-2)", fontWeight: 540 }}>
{formatSchedule(post.scheduledFor)}
</span>
)}
{approvalState === APPROVAL_STATE.PENDING && <Pill tone="amber" dot>Awaiting approval</Pill>}
{approvalState === APPROVAL_STATE.APPROVED && <Pill tone="green" dot>Approved</Pill>}
{approvalState === APPROVAL_STATE.DECLINED && <Pill tone="red">Declined</Pill>}
{post.state === "published" && <Pill tone="green" dot>Published</Pill>}
{post.state === "cancelled" && <Pill tone="red">Cancelled</Pill>}
</div>
<div style={{ fontSize: 14, color: "var(--text-1)", lineHeight: 1.5, marginBottom: 8, whiteSpace: "pre-wrap" }}>{post.bodyMd}</div>
<div className="row" style={{ gap: 6 }}>
<Avatar name={authorName} size="sm" bot={post.createdByEmployeeId === null} />
<span style={{ fontSize: 12, color: "var(--text-3)" }}>By {authorName}</span>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{post.state === "scheduled" && <Button size="small" tone="primary" disabled={!canPublish} onClick={onPublish}>Publish</Button>}
{post.state === "draft" && <Button size="small" tone="primary" onClick={onPublish}>Schedule</Button>}
{post.state !== "cancelled" && post.state !== "published" && (
<Button size="small" tone="ghost" onClick={onCancel}>Cancel</Button>
)}
</div>
</div>
);
}
function approvalState(post: ScheduledPostRecord, states: Map<string, ApprovalState>): ApprovalState | null {
if (!post.approvalId) return null;
const state = states.get(post.approvalId);
if (!state) throw new Error("Scheduled post references missing approval.");
return state;
}
function requireAccount(accounts: SocialAccountRecord[], id: string): SocialAccountRecord {
const account = accounts.find((item) => item.id === id);
if (!account) throw new Error("Scheduled post references missing account.");
return account;
}
function authorName(post: ScheduledPostRecord, employees: EmployeeRecord[]): string {
if (!post.createdByEmployeeId) return "System";
return employees.find((item) => item.id === post.createdByEmployeeId)?.displayName ?? "System";
}
function formatSchedule(iso: string): string {
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return iso;
return date.toLocaleString(undefined, {
weekday: "short",
hour: "2-digit",
minute: "2-digit",
});
}
@@ -0,0 +1,103 @@
import { Pill } from "../../shared/ui";
import type { AgentRecord, ConnectorAccountRecord, ConnectorGrantRecord } from "../../../../shared/domain";
import { ToolkitTile } from "./ToolkitCatalog";
const STATUS_TONE: Record<string, "green" | "amber" | "red" | undefined> = {
ACTIVE: "green",
INITIATED: "amber",
INITIALIZING: "amber",
FAILED: "red",
EXPIRED: "red",
REVOKED: "red",
INACTIVE: "red",
};
/**
* The company's connected toolkits, each with the roster of agents the CEO
* or the board has granted access to. Toggling a chip grants or revokes.
*/
export function ConnectedList({
accounts,
grants,
agents,
onGrant,
onRevoke,
}: {
accounts: ConnectorAccountRecord[];
grants: ConnectorGrantRecord[];
agents: AgentRecord[];
onGrant: (agentId: string, toolkitSlug: string) => void;
onRevoke: (agentId: string, toolkitSlug: string) => void;
}) {
if (accounts.length === 0) return null;
return (
<section style={{ marginBottom: 24 }}>
<div className="section-label" style={{ marginBottom: 12 }}>Connected</div>
<div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
{accounts.map((account) => (
<ConnectedRow
key={account.id}
account={account}
grantedAgentIds={new Set(grants.filter((grant) => grant.toolkitSlug === account.toolkitSlug).map((grant) => grant.agentId))}
agents={agents}
onGrant={onGrant}
onRevoke={onRevoke}
/>
))}
</div>
</section>
);
}
function ConnectedRow({
account,
grantedAgentIds,
agents,
onGrant,
onRevoke,
}: {
account: ConnectorAccountRecord;
grantedAgentIds: Set<string>;
agents: AgentRecord[];
onGrant: (agentId: string, toolkitSlug: string) => void;
onRevoke: (agentId: string, toolkitSlug: string) => void;
}) {
const active = account.status === "ACTIVE";
return (
<div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
<div className="row" style={{ gap: 10 }}>
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} size={30} />
<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>
</div>
{active && agents.length > 0 && (
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
{agents.map((agent) => {
const granted = grantedAgentIds.has(agent.id);
return (
<button
key={agent.id}
type="button"
aria-pressed={granted}
aria-label={`${granted ? "Revoke" : "Grant"} ${account.toolkitName} for ${agent.name}`}
onClick={() => (granted ? onRevoke(agent.id, account.toolkitSlug) : onGrant(agent.id, account.toolkitSlug))}
style={{
border: granted ? "1px solid var(--accent)" : "1px solid var(--border-strong)",
background: granted ? "var(--accent-soft)" : "var(--panel)",
color: granted ? "var(--accent)" : "var(--text-2)",
borderRadius: 999,
padding: "4px 12px",
fontSize: 12.5,
fontWeight: 540,
cursor: "pointer",
}}
>
{agent.name}
</button>
);
})}
</div>
)}
</div>
);
}
@@ -0,0 +1,132 @@
import { useMemo, useState } from "react";
import { Button, Icon, Pill } from "../../shared/ui";
import type { ComposioToolkitSummaryDto } from "../../../../shared/commands";
/** Deterministic tile color for a toolkit's letter glyph. */
const TILE_COLORS = ["#5e6ad2", "#3b82a0", "#7c5cb0", "#3f8f5f", "#b0684f", "#a05c86", "#6f7d3d", "#4f6fb0"];
function tileColor(slug: string): string {
let hash = 0;
for (let i = 0; i < slug.length; i++) hash = (hash * 31 + slug.charCodeAt(i)) | 0;
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
}
export function ToolkitTile({ name, slug, size = 34 }: { name: string; slug: string; size?: number }) {
return (
<span
style={{
width: size,
height: size,
borderRadius: 9,
background: tileColor(slug),
color: "#fff",
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
fontSize: size * 0.44,
fontWeight: 600,
flexShrink: 0,
}}
>
{(name || slug).charAt(0).toUpperCase()}
</span>
);
}
export function ToolkitCatalog({
toolkits,
connectedSlugs,
busySlug,
onConnect,
}: {
toolkits: ComposioToolkitSummaryDto[];
connectedSlugs: Set<string>;
busySlug: string | null;
onConnect: (toolkit: ComposioToolkitSummaryDto) => void;
}) {
const [query, setQuery] = useState("");
const filtered = useMemo(() => {
const trimmed = query.trim().toLowerCase();
if (!trimmed) return toolkits;
return toolkits.filter((toolkit) =>
toolkit.name.toLowerCase().includes(trimmed) ||
toolkit.slug.includes(trimmed) ||
toolkit.categories.some((category) => category.toLowerCase().includes(trimmed)),
);
}, [toolkits, query]);
return (
<section>
<div className="row" style={{ marginBottom: 12, gap: 10 }}>
<div className="section-label" style={{ flex: 1 }}>Catalog</div>
<div className="row" style={{ gap: 6, background: "var(--panel-2)", borderRadius: 8, padding: "5px 10px", width: 240 }}>
<Icon name="search" size={13} color="var(--text-3)" />
<input
aria-label="Search connectors"
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Search connectors"
style={{ border: 0, outline: 0, background: "transparent", fontSize: 13, flex: 1, minWidth: 0, color: "var(--text-1)" }}
/>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))", gap: 10 }}>
{filtered.map((toolkit) => {
const connected = connectedSlugs.has(toolkit.slug);
return (
<div
key={toolkit.slug}
style={{
border: "1px solid var(--border)",
borderRadius: 12,
padding: 12,
background: "var(--panel)",
display: "flex",
gap: 10,
alignItems: "flex-start",
}}
>
<ToolkitTile name={toolkit.name} slug={toolkit.slug} />
<div style={{ flex: 1, minWidth: 0 }}>
<div className="row" style={{ gap: 6 }}>
<span style={{ fontWeight: 600, fontSize: 13.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{toolkit.name}
</span>
{toolkit.categories[0] && <Pill>{toolkit.categories[0]}</Pill>}
</div>
<div
style={{
fontSize: 12,
color: "var(--text-3)",
lineHeight: 1.45,
marginTop: 2,
display: "-webkit-box",
WebkitLineClamp: 2,
WebkitBoxOrient: "vertical",
overflow: "hidden",
minHeight: 34,
}}
>
{toolkit.description}
</div>
<div className="row" style={{ marginTop: 8 }}>
{connected ? (
<Pill tone="green" dot>Connected</Pill>
) : (
<Button
size="small"
disabled={busySlug !== null}
onClick={() => onConnect(toolkit)}
>
{busySlug === toolkit.slug ? "Opening…" : "Connect"}
</Button>
)}
</div>
</div>
</div>
);
})}
</div>
</section>
);
}
@@ -23,7 +23,7 @@ const NAV_ICONS: Record<RouteId, typeof Home> = {
files: Folder,
approvals: ShieldCheck,
docs: DocumentText2,
social: Plug2,
connectors: Plug2,
office: Buildings2,
settings: Gear,
};
@@ -134,7 +134,7 @@ function routeFor(entityType: SearchEntityType): RouteId {
if (entityType === "chat_space" || entityType === "chat_message") return "chat";
if (entityType === "file") return "files";
if (entityType === "provider") return "settings";
if (entityType === "social_account" || entityType === "scheduled_post") return "social";
if (entityType === "social_account" || entityType === "scheduled_post") return "connectors";
return "work";
}
@@ -15,7 +15,7 @@ export type RouteId =
| "docs"
| "runs"
| "approvals"
| "social"
| "connectors"
| "office"
| "settings";
@@ -26,7 +26,7 @@ interface NavSpec {
/** Routes whose pages own a full-height, multi-pane layout with their own
* internal scroll regions, so the content area must not scroll as one block. */
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "social", "runs", "office"]);
const FLUSH_ROUTES = new Set<RouteId>(["chat", "settings", "files", "runs", "office"]);
/** The daily-driver surfaces — the core operating loop. */
const primaryNav: NavSpec[] = [
@@ -42,7 +42,7 @@ const primaryNav: NavSpec[] = [
/** Less-frequent surfaces, demoted under a divider so the primary set leads. */
const secondaryNav: NavSpec[] = [
{ id: "docs", label: "Documents" },
{ id: "social", label: "Social" },
{ id: "connectors", label: "Connectors" },
{ id: "office", label: "Office" },
];
+6 -24
View File
@@ -106,29 +106,11 @@ test("onboards, hires, and creates work in the Electron shell", async () => {
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Escalate release").first()).toBeVisible();
await page.getByRole("button", { name: "Social" }).click();
await page.getByRole("button", { name: "Connect account" }).click();
await page.getByLabel("Handle").fill("@acme");
await page.getByLabel("Display name").fill("Acme Social");
await page.locator(".modal").getByRole("button", { name: "Connect" }).click();
await expect(page.getByText("@acme").first()).toBeVisible();
await page.getByRole("button", { name: "Draft post" }).click();
await page.getByLabel("Body").fill("Pilot starts Friday.");
await page.getByLabel("Scheduled for").fill("2027-01-15T09:30");
await page.locator(".modal").getByRole("button", { name: "Save" }).click();
await expect(page.getByText("Draft").first()).toBeVisible();
await page.getByRole("button", { name: "Schedule" }).click();
await expect(page.getByText("Awaiting approval").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Publish" })).toBeDisabled();
await page.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText("Approve social post").first()).toBeVisible();
await page.getByLabel("Filter risk").selectOption("medium");
await page.getByRole("button", { name: /^Approve$/ }).first().click();
await page.getByRole("button", { name: "Social" }).click();
await expect(page.getByText("Approved").first()).toBeVisible();
await page.getByRole("button", { name: "Publish" }).click();
await expect(page.getByText("Published").first()).toBeVisible();
// The Connectors page shell renders whether or not the Composio catalog
// is reachable from the test machine.
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
await page.getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: "Providers" }).click();
@@ -218,7 +200,7 @@ function createSkillDirectory(): string {
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
await page.setViewportSize(size);
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Social", "Office", "Settings"]) {
for (const nav of ["Home", "Team", "Work", "Messages", "Files", "Approvals", "Connectors", "Office", "Settings"]) {
await page.getByRole("button", { name: nav, exact: true }).click();
await expectPageToFit(page);
}
@@ -46,7 +46,7 @@ test("captures every page for visual review", async () => {
await go(page, "Messages", outDir, "13-messages");
await go(page, "Files", outDir, "14-files");
await go(page, "Approvals", outDir, "15-approvals");
await go(page, "Social", outDir, "16-social");
await go(page, "Connectors", outDir, "16-connectors");
await go(page, "Settings", outDir, "17-settings");
await go(page, "Office", outDir, "18-office");