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:
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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
Reference in new issue
Block a user