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 { ChatPage } from "../pages/ChatPage";
|
||||||
import { FilesPage } from "../pages/FilesPage";
|
import { FilesPage } from "../pages/FilesPage";
|
||||||
import { ApprovalsPage } from "../pages/ApprovalsPage";
|
import { ApprovalsPage } from "../pages/ApprovalsPage";
|
||||||
import { SocialPage } from "../pages/SocialPage";
|
import { ConnectorsPage } from "../pages/ConnectorsPage";
|
||||||
import { DocumentsPage } from "../pages/DocumentsPage";
|
import { DocumentsPage } from "../pages/DocumentsPage";
|
||||||
import { RunsPage } from "../pages/RunsPage";
|
import { RunsPage } from "../pages/RunsPage";
|
||||||
import { SettingsPage } from "../pages/SettingsPage";
|
import { SettingsPage } from "../pages/SettingsPage";
|
||||||
@@ -43,7 +43,7 @@ export function App() {
|
|||||||
docs: <DocumentsPage {...pageProps} />,
|
docs: <DocumentsPage {...pageProps} />,
|
||||||
runs: <RunsPage {...pageProps} />,
|
runs: <RunsPage {...pageProps} />,
|
||||||
approvals: <ApprovalsPage {...pageProps} />,
|
approvals: <ApprovalsPage {...pageProps} />,
|
||||||
social: <SocialPage {...pageProps} />,
|
connectors: <ConnectorsPage {...pageProps} />,
|
||||||
office: <OfficePage {...pageProps} />,
|
office: <OfficePage {...pageProps} />,
|
||||||
settings: <SettingsPage {...pageProps} />,
|
settings: <SettingsPage {...pageProps} />,
|
||||||
}[route];
|
}[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,
|
files: Folder,
|
||||||
approvals: ShieldCheck,
|
approvals: ShieldCheck,
|
||||||
docs: DocumentText2,
|
docs: DocumentText2,
|
||||||
social: Plug2,
|
connectors: Plug2,
|
||||||
office: Buildings2,
|
office: Buildings2,
|
||||||
settings: Gear,
|
settings: Gear,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ function routeFor(entityType: SearchEntityType): RouteId {
|
|||||||
if (entityType === "chat_space" || entityType === "chat_message") return "chat";
|
if (entityType === "chat_space" || entityType === "chat_message") return "chat";
|
||||||
if (entityType === "file") return "files";
|
if (entityType === "file") return "files";
|
||||||
if (entityType === "provider") return "settings";
|
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";
|
return "work";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type RouteId =
|
|||||||
| "docs"
|
| "docs"
|
||||||
| "runs"
|
| "runs"
|
||||||
| "approvals"
|
| "approvals"
|
||||||
| "social"
|
| "connectors"
|
||||||
| "office"
|
| "office"
|
||||||
| "settings";
|
| "settings";
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ interface NavSpec {
|
|||||||
|
|
||||||
/** Routes whose pages own a full-height, multi-pane layout with their own
|
/** 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. */
|
* 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. */
|
/** The daily-driver surfaces — the core operating loop. */
|
||||||
const primaryNav: NavSpec[] = [
|
const primaryNav: NavSpec[] = [
|
||||||
@@ -42,7 +42,7 @@ const primaryNav: NavSpec[] = [
|
|||||||
/** Less-frequent surfaces, demoted under a divider so the primary set leads. */
|
/** Less-frequent surfaces, demoted under a divider so the primary set leads. */
|
||||||
const secondaryNav: NavSpec[] = [
|
const secondaryNav: NavSpec[] = [
|
||||||
{ id: "docs", label: "Documents" },
|
{ id: "docs", label: "Documents" },
|
||||||
{ id: "social", label: "Social" },
|
{ id: "connectors", label: "Connectors" },
|
||||||
{ id: "office", label: "Office" },
|
{ 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 page.getByRole("button", { name: "Approvals" }).click();
|
||||||
await expect(page.getByText("Escalate release").first()).toBeVisible();
|
await expect(page.getByText("Escalate release").first()).toBeVisible();
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Social" }).click();
|
// The Connectors page shell renders whether or not the Composio catalog
|
||||||
await page.getByRole("button", { name: "Connect account" }).click();
|
// is reachable from the test machine.
|
||||||
await page.getByLabel("Handle").fill("@acme");
|
await page.getByRole("button", { name: "Connectors", exact: true }).click();
|
||||||
await page.getByLabel("Display name").fill("Acme Social");
|
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
|
||||||
await page.locator(".modal").getByRole("button", { name: "Connect" }).click();
|
await expect(page.getByRole("button", { name: "Refresh" })).toBeVisible();
|
||||||
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();
|
|
||||||
|
|
||||||
await page.getByRole("button", { name: "Settings" }).click();
|
await page.getByRole("button", { name: "Settings" }).click();
|
||||||
await page.getByRole("button", { name: "Providers" }).click();
|
await page.getByRole("button", { name: "Providers" }).click();
|
||||||
@@ -218,7 +200,7 @@ function createSkillDirectory(): string {
|
|||||||
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
async function verifyPagesDoNotOverflow(page: Page): Promise<void> {
|
||||||
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
for (const size of [{ width: 1280, height: 860 }, { width: 980, height: 700 }]) {
|
||||||
await page.setViewportSize(size);
|
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 page.getByRole("button", { name: nav, exact: true }).click();
|
||||||
await expectPageToFit(page);
|
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, "Messages", outDir, "13-messages");
|
||||||
await go(page, "Files", outDir, "14-files");
|
await go(page, "Files", outDir, "14-files");
|
||||||
await go(page, "Approvals", outDir, "15-approvals");
|
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, "Settings", outDir, "17-settings");
|
||||||
await go(page, "Office", outDir, "18-office");
|
await go(page, "Office", outDir, "18-office");
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user