Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 58 additions & 0 deletions apps/web/src/bench/drawer.css
Original file line number Diff line number Diff line change
Expand Up @@ -80,3 +80,61 @@
border-radius: 0;
background: none;
}

.drawer-perm,
.drawer-tool {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.drawer-tool {
padding: 8px 0;
}
.drawer-perm-name {
min-width: 0;
overflow: hidden;
font-family: var(--font-mono, "Space Mono", monospace);
font-size: 12.5px;
font-weight: 400;
text-overflow: ellipsis;
white-space: nowrap;
}
.drawer-seg {
display: inline-flex;
flex-shrink: 0;
gap: 2px;
padding: 2px;
border-radius: 999px;
background: var(--surface-sunk);
}
.drawer-seg button {
height: 26px;
padding: 0 10px;
border: 0;
border-radius: 999px;
background: none;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-2);
cursor: pointer;
}
.drawer-seg button.active {
background: var(--surface);
color: var(--ink);
box-shadow: var(--raised, 0 1px 2px rgb(0 0 0 / 12%));
}
.drawer-seg button:disabled {
cursor: default;
}
.drawer-list-link {
justify-content: space-between;
}
.drawer-list-link > .drawer-list-text {
flex: 1;
}
.drawer-list-text > b {
font-weight: 800;
}
97 changes: 40 additions & 57 deletions apps/web/src/bench/grants-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { ConfirmButton } from "@corbits/react-ui";
import { toast } from "@corbits/react-ui/ui/toast";
import type { GrantEffect } from "@intx/types";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
Expand All @@ -8,31 +7,23 @@ import { QueryView, describeApiError, toAPIQuery } from "@/lib/api-query";
import { tenantKeys } from "@/query-client";
import { GRANT_RESOURCE_LABEL, type GrantResource } from "../settings/resource-vocabulary";
import { principalLabel } from "../settings/identity";
import { listGrants, revokeGrant, type Grant } from "../settings/tenancy-api";
import { listGrants, updateGrant, type Grant } from "../settings/tenancy-api";
import { useGrantNames } from "./grant-names";
import "./drawer.css";

const MODE: Record<GrantEffect, string> = {
allow: "Always allow",
ask: "Ask first",
deny: "Deny",
};
const EFFECTS: readonly { readonly effect: GrantEffect; readonly label: string }[] = [
{ effect: "allow", label: "Allow" },
{ effect: "ask", label: "Ask" },
{ effect: "deny", label: "Deny" },
];

const TOOL_PREFIX = "tool:";

/** "tool:artifact_link_file" reads "Artifact link file"; other resources keep
* their vocabulary label. */
/** A tool grant shows the tool's native name; other resources keep their
* vocabulary label. */
function resourceName(resource: string): string {
if (!resource.startsWith(TOOL_PREFIX)) {
return GRANT_RESOURCE_LABEL[resource as GrantResource] ?? resource;
}
const text = resource
.slice(TOOL_PREFIX.length)
.split(/[_.\-\s]+/)
.filter(
(word, index, all) => word !== "" && word.toLowerCase() !== all[index - 1]?.toLowerCase(),
)
.join(" ");
return text.charAt(0).toUpperCase() + text.slice(1);
if (resource.startsWith(TOOL_PREFIX)) return resource.slice(TOOL_PREFIX.length);
return GRANT_RESOURCE_LABEL[resource as GrantResource] ?? resource;
}

/** This workbench's own grants; they never inherit from the workspace. */
Expand All @@ -50,8 +41,7 @@ export function GrantsTab({
if (known !== undefined) return known.name;
if (grant.principalName === undefined || grant.principalName === null) return "Everyone here";
const named = names.replaceTenantIds(grant.principalName);
// A raw run id is never shown, and never turned into words; with no
// resolvable name the row has no subtitle.
// A raw run id is never shown; with no resolvable name the row has no hint.
return principalLabel(named).raw === null ? named : undefined;
};
const queryClient = useQueryClient();
Expand All @@ -61,14 +51,14 @@ export function GrantsTab({
queryFn: () => listGrants(workbenchTenantId, {}),
}),
);
const revoke = useMutation({
mutationFn: (grant: Grant) => revokeGrant(workbenchTenantId, grant.id),
onSuccess: () => {
const setEffect = useMutation({
mutationFn: (input: { readonly grant: Grant; readonly effect: GrantEffect }) =>
updateGrant(workbenchTenantId, input.grant.id, { effect: input.effect }),
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: tenantKeys.grants(workbenchTenantId) });
toast("Grant revoked.");
},
onError: (cause: unknown) => {
toast(describeApiError(cause, "revoking this grant"));
toast(describeApiError(cause, "changing this grant"));
},
});

Expand All @@ -85,40 +75,33 @@ export function GrantsTab({
grants.length === 0 ? (
<p className="workbench-info-empty-note">No grants here yet.</p>
) : (
<ul className="bench-tab-list">
<div>
{grants.map((grant) => {
const title = names.resource(grant.resource) ?? resourceName(grant.resource);
const name = who(grant);
const subtitle =
name !== undefined && title.toLowerCase().split(" ").includes(name.toLowerCase())
? undefined
: name;
const label = names.resource(grant.resource) ?? resourceName(grant.resource);
return (
<li key={grant.id} className="bench-tab-row">
<span className="bench-tab-text">
<span className="workbench-info-cell-primary">{title}</span>
{subtitle === undefined ? null : (
<span className="workbench-info-cell-context">{subtitle}</span>
)}
</span>
<span className="drawer-grant-mode" data-effect={grant.effect}>
{MODE[grant.effect]}
</span>
<ConfirmButton
variant="ghost"
size="sm"
disabled={revoke.isPending}
confirmLabel="Revoke?"
onConfirm={() => {
revoke.mutate(grant);
}}
>
Revoke
</ConfirmButton>
</li>
<div key={grant.id} className="drawer-perm" title={who(grant)}>
<b className="drawer-perm-name">{label}</b>
<div className="drawer-seg" role="radiogroup" aria-label={label}>
{EFFECTS.map(({ effect, label: text }) => (
<button
key={effect}
type="button"
role="radio"
aria-checked={effect === grant.effect}
className={effect === grant.effect ? "active" : undefined}
disabled={setEffect.isPending}
onClick={() => {
if (effect !== grant.effect) setEffect.mutate({ grant, effect });
}}
>
{text}
</button>
))}
</div>
</div>
);
})}
</ul>
</div>
)
}
</QueryView>
Expand Down
113 changes: 71 additions & 42 deletions apps/web/src/bench/members-tab.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,74 @@
import { RichEmptyState, Skeleton } from "@corbits/react-ui";
import { useQuery } from "@tanstack/react-query";

import { IdentityAvatar, WorkbenchAvatar } from "@/chat/avatar";
import type { WorkbenchParticipant } from "@/chat/threads-api";
import { WorkbenchAvatar } from "@/chat/avatar";
import { listChatAgents, type WorkbenchParticipant } from "@/chat/threads-api";
import { CaretRight } from "@/lib/icons";
import { Link } from "../navigation";
import { tenantKeys } from "../query-client";
import { principalLabel } from "../settings/identity";
import { listPrincipals } from "../settings/tenancy-api";
import { useWorkerRole } from "../worker-role-query";
import { useWorkerStatus } from "../worker-status";
import "./drawer.css";

/** Humans are the bench tenant's user principals; agents are the bench's
* workers, already listed as participants. */
function capitalize(text: string): string {
return text.charAt(0).toUpperCase() + text.slice(1);
}

function WorkerRow({
workbenchTenantId,
participant,
}: {
readonly workbenchTenantId: string;
readonly participant: WorkbenchParticipant;
}) {
const agents = useQuery({
queryKey: tenantKeys.agents(workbenchTenantId),
queryFn: () => listChatAgents(workbenchTenantId),
});
const agent = agents.data?.find((a) => a.id === participant.id);
const status = useWorkerStatus(workbenchTenantId, participant.id);
const body = (
<>
<WorkbenchAvatar kind="worker" name={participant.name} size="md" status={status.tone} />
<span className="drawer-list-text">
<b>{participant.name}</b>
{agent === undefined ? null : (
<WorkerRoleLine workbenchTenantId={workbenchTenantId} agent={agent} />
)}
</span>
<CaretRight size={14} aria-hidden="true" />
</>
);
return (
<li>
<Link className="drawer-list-link" to={`/workers/${encodeURIComponent(participant.id)}`}>
{body}
</Link>
</li>
);
}

function WorkerRoleLine({
workbenchTenantId,
agent,
}: {
readonly workbenchTenantId: string;
readonly agent: Parameters<typeof useWorkerRole>[1];
}) {
return <span>{useWorkerRole(workbenchTenantId, agent)}</span>;
}

/** Humans are the bench tenant's user principals; workers are the bench's
* agents, already listed as participants. */
export function MembersTab({
workbenchTenantId,
participants,
loading,
}: {
readonly workbenchTenantId: string;
/** True until the roster resolves; an empty list then isn't "no agents". */
/** True until the roster resolves; an empty list then isn't "no workers". */
readonly loading: boolean;
readonly participants: readonly WorkbenchParticipant[];
}) {
Expand All @@ -26,13 +78,14 @@ export function MembersTab({
queryKey: [...tenantKeys.principals(workbenchTenantId), "members"],
queryFn: async () => (await listPrincipals(workbenchTenantId)).filter((p) => p.kind === "user"),
});
const agents = participants.filter((p) => p.kind === "agent");
const workers = participants.filter((p) => p.kind === "agent");

return (
<div>
<section className="drawer-sec">
<div className="drawer-sec-head">
<h3>People</h3>
<Link to="/settings/people">Invite</Link>
</div>
{people.isPending ? <Skeleton className="h-12 w-full" /> : null}
{people.isError ? (
Expand All @@ -47,16 +100,14 @@ export function MembersTab({
) : null}
<ul className="drawer-list">
{(people.data ?? []).map((person) => {
const { label } = principalLabel(person.displayName);
const detail = [person.email, person.roles.map((r) => r.name).join(", ")]
.filter((s): s is string => s !== undefined && s !== "")
.join(" · ");
const name = capitalize(principalLabel(person.displayName).label);
const role = person.roles.map((r) => capitalize(r.name)).join(", ");
return (
<li key={person.id}>
<IdentityAvatar kind="person" name={label} principalId={person.id} />
<WorkbenchAvatar kind="person" name={name} size="md" />
<span className="drawer-list-text">
<b>{label}</b>
<span>{detail}</span>
<b>{name}</b>
{role === "" ? null : <span>{role}</span>}
</span>
</li>
);
Expand All @@ -65,39 +116,17 @@ export function MembersTab({
</section>
<section className="drawer-sec">
<div className="drawer-sec-head">
<h3>Agents</h3>
<h3>Workers</h3>
<Link to="/workers">Add</Link>
</div>
{loading ? <Skeleton className="h-12 w-full" /> : null}
{!loading && agents.length === 0 ? (
<p className="workbench-info-empty-note">No agents yet.</p>
{!loading && workers.length === 0 ? (
<p className="workbench-info-empty-note">No workers yet.</p>
) : null}
<ul className="drawer-list">
{agents.map((agent) => {
const workerId = agent.assetName === undefined ? undefined : agent.id;
const body = (
<>
<WorkbenchAvatar kind="worker" name={agent.name} size="md" />
<span className="drawer-list-text">
<b>{agent.name}</b>
<span>{agent.address === "" ? "Starting" : "Running"}</span>
</span>
</>
);
return (
<li key={agent.id}>
{workerId === undefined ? (
body
) : (
<Link
className="drawer-list-link"
to={`/workers/${encodeURIComponent(workerId)}`}
>
{body}
</Link>
)}
</li>
);
})}
{workers.map((worker) => (
<WorkerRow key={worker.id} workbenchTenantId={workbenchTenantId} participant={worker} />
))}
</ul>
</section>
</div>
Expand Down
Loading
Loading