Make the CEO the grant authority: implicit all-access, agent-tool grants, no manual assignment UI
This commit is contained in:
1 parent
934fa71486
commit
defe8f12b1
15 files changed
+327
-190
No files matched your search
@@ -1,19 +1,18 @@
|
||||
import { useMemo } from "react";
|
||||
import { Button } from "../shared/ui";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee } from "./types";
|
||||
import { activeOrgId } from "./types";
|
||||
import { ConnectedList } from "./connectors/ConnectedList";
|
||||
import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
|
||||
import { useConnectorCatalog } from "./connectors/useConnectorCatalog";
|
||||
|
||||
/**
|
||||
* The company's connector control room: what is connected, who may use it,
|
||||
* and the configured catalog to connect more. Connections are org-level; the
|
||||
* grant chips decide which agents carry each toolkit's tools.
|
||||
* The company's connector control room: what is connected, who holds it, and
|
||||
* the configured catalog to connect more. Connections are org-level; the CEO
|
||||
* decides which agents carry each toolkit's tools.
|
||||
*/
|
||||
export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
const orgId = activeOrgId(snapshot);
|
||||
const employee = requireActiveEmployee(snapshot);
|
||||
const { catalog, busySlug, connectError, connect, retry } = useConnectorCatalog(orgId, actions);
|
||||
|
||||
const accounts = useMemo(
|
||||
@@ -40,12 +39,6 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
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 }}>
|
||||
<h2 style={{ margin: "0 0 18px", fontSize: 16, fontWeight: 600 }}>Connectors</h2>
|
||||
@@ -65,14 +58,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectedList
|
||||
accounts={accounts}
|
||||
grants={grants}
|
||||
agents={agents}
|
||||
logoBySlug={logoBySlug}
|
||||
onGrant={grant}
|
||||
onRevoke={revoke}
|
||||
/>
|
||||
<ConnectedList accounts={accounts} grants={grants} agents={agents} logoBySlug={logoBySlug} />
|
||||
|
||||
{catalog.phase === "error" && (
|
||||
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
|
||||
|
||||
@@ -14,95 +14,52 @@ const STATUS_TONE: Record<string, "green" | "amber" | "red" | undefined> = {
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* has granted access to. Grants are the CEO's call — made with its
|
||||
* grant_connector tool — so the roster here is read-only.
|
||||
*/
|
||||
export function ConnectedList({
|
||||
accounts,
|
||||
grants,
|
||||
agents,
|
||||
logoBySlug,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
accounts: ConnectorAccountRecord[];
|
||||
grants: ConnectorGrantRecord[];
|
||||
agents: AgentRecord[];
|
||||
logoBySlug: Map<string, string | null>;
|
||||
onGrant: (agentId: string, toolkitSlug: string) => void;
|
||||
onRevoke: (agentId: string, toolkitSlug: string) => void;
|
||||
}) {
|
||||
if (accounts.length === 0) return null;
|
||||
const agentNames = new Map(agents.map((agent) => [agent.id, agent.name]));
|
||||
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}
|
||||
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
|
||||
onGrant={onGrant}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
))}
|
||||
{accounts.map((account) => {
|
||||
const holders = grants
|
||||
.filter((grant) => grant.toolkitSlug === account.toolkitSlug)
|
||||
.map((grant) => agentNames.get(grant.agentId))
|
||||
.filter((name): name is string => Boolean(name));
|
||||
return (
|
||||
<div key={account.id} 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}
|
||||
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
|
||||
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>
|
||||
{holders.length > 0 && (
|
||||
<div className="row" style={{ gap: 6, marginTop: 12, flexWrap: "wrap" }}>
|
||||
{holders.map((name) => <Pill key={name}>{name}</Pill>)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ConnectedRow({
|
||||
account,
|
||||
grantedAgentIds,
|
||||
agents,
|
||||
logoUrl,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
account: ConnectorAccountRecord;
|
||||
grantedAgentIds: Set<string>;
|
||||
agents: AgentRecord[];
|
||||
logoUrl: string | null;
|
||||
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} logoUrl={logoUrl} 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>
|
||||
);
|
||||
}
|
||||
@@ -39,12 +39,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
})).then((result) => { if (result) setImportOpen(false); });
|
||||
};
|
||||
|
||||
const bind = (skill: SkillDefinitionRecord, agentId: string) => {
|
||||
const latest = revisions.find((revision) => revision.skillId === skill.id);
|
||||
if (!latest) return;
|
||||
void actions.run(() => window.agentCompany.bindSkill({ orgId, agentId, skillId: skill.id, revisionId: latest.id }));
|
||||
};
|
||||
|
||||
const runTests = (revisionId: string) => {
|
||||
void actions.run(() => window.agentCompany.runSkillTests({ orgId, revisionId }));
|
||||
};
|
||||
@@ -61,9 +55,9 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<div className="card" style={{ padding: 0, overflow: "hidden" }}>
|
||||
<div
|
||||
className="table-row head"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)" }}
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px" }}
|
||||
>
|
||||
<div>Skill</div><div>Version</div><div>Tests</div><div>Bindings</div><div>Actions</div>
|
||||
<div>Skill</div><div>Version</div><div>Tests</div><div>Held by</div><div>Actions</div>
|
||||
</div>
|
||||
{skills.map((skill, index) => {
|
||||
const last = index === skills.length - 1;
|
||||
@@ -74,7 +68,7 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<div
|
||||
key={skill.id}
|
||||
className="table-row"
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) minmax(220px, 1.4fr)", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
style={{ gridTemplateColumns: "minmax(0, 1.4fr) 92px 132px minmax(0, 1.2fr) 132px", borderBottom: last ? "none" : "1px solid var(--border-soft)" }}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontWeight: 540, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>{skill.name}</div>
|
||||
@@ -87,9 +81,8 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
<Pill key={binding.id}>{agentName(binding.agentId, agents)}</Pill>
|
||||
))}
|
||||
</div>
|
||||
<div className="row" style={{ gap: 8, minWidth: 0 }}>
|
||||
<div className="row" style={{ minWidth: 0 }}>
|
||||
<Button size="small" onClick={() => { if (latest && revisionTests.length > 0) runTests(latest.id); }} disabled={!latest || revisionTests.length === 0}>Run tests</Button>
|
||||
<BindAgent skill={skill} agents={agents} onBind={(agentId) => bind(skill, agentId)} disabled={!latest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -104,35 +97,6 @@ export function SkillsPanel({ orgId, skills, revisions, bindings, tests, agents,
|
||||
);
|
||||
}
|
||||
|
||||
function BindAgent({
|
||||
skill: _skill,
|
||||
agents,
|
||||
onBind,
|
||||
disabled,
|
||||
}: {
|
||||
skill: SkillDefinitionRecord;
|
||||
agents: AgentRecord[];
|
||||
onBind: (agentId: string) => void;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
if (agents.length === 0) return null;
|
||||
return (
|
||||
<select
|
||||
className="input"
|
||||
style={{ height: 28, padding: "0 8px", fontSize: 12 }}
|
||||
defaultValue=""
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
if (event.target.value) onBind(event.target.value);
|
||||
event.target.value = "";
|
||||
}}
|
||||
>
|
||||
<option value="">Pick an agent…</option>
|
||||
{agents.map((agent) => <option key={agent.id} value={agent.id}>{agent.name}</option>)}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
function agentName(agentId: string, agents: AgentRecord[]): string {
|
||||
return agents.find((agent) => agent.id === agentId)?.name ?? agentId;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user