Skip to content

Commit 4635e59

Browse files
fix(web): the drawer's Grants, Tools and Members tabs match the mockup (CL-9665) (#1072)
1 parent 6b43478 commit 4635e59

4 files changed

Lines changed: 210 additions & 138 deletions

File tree

‎apps/web/src/bench/drawer.css‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,3 +80,61 @@
8080
border-radius: 0;
8181
background: none;
8282
}
83+
84+
.drawer-perm,
85+
.drawer-tool {
86+
display: flex;
87+
align-items: center;
88+
justify-content: space-between;
89+
gap: 12px;
90+
padding: 10px 0;
91+
border-top: 1px solid var(--line);
92+
}
93+
.drawer-tool {
94+
padding: 8px 0;
95+
}
96+
.drawer-perm-name {
97+
min-width: 0;
98+
overflow: hidden;
99+
font-family: var(--font-mono, "Space Mono", monospace);
100+
font-size: 12.5px;
101+
font-weight: 400;
102+
text-overflow: ellipsis;
103+
white-space: nowrap;
104+
}
105+
.drawer-seg {
106+
display: inline-flex;
107+
flex-shrink: 0;
108+
gap: 2px;
109+
padding: 2px;
110+
border-radius: 999px;
111+
background: var(--surface-sunk);
112+
}
113+
.drawer-seg button {
114+
height: 26px;
115+
padding: 0 10px;
116+
border: 0;
117+
border-radius: 999px;
118+
background: none;
119+
font-size: 12.5px;
120+
font-weight: 600;
121+
color: var(--ink-2);
122+
cursor: pointer;
123+
}
124+
.drawer-seg button.active {
125+
background: var(--surface);
126+
color: var(--ink);
127+
box-shadow: var(--raised, 0 1px 2px rgb(0 0 0 / 12%));
128+
}
129+
.drawer-seg button:disabled {
130+
cursor: default;
131+
}
132+
.drawer-list-link {
133+
justify-content: space-between;
134+
}
135+
.drawer-list-link > .drawer-list-text {
136+
flex: 1;
137+
}
138+
.drawer-list-text > b {
139+
font-weight: 800;
140+
}

‎apps/web/src/bench/grants-tab.tsx‎

Lines changed: 40 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import { ConfirmButton } from "@corbits/react-ui";
21
import { toast } from "@corbits/react-ui/ui/toast";
32
import type { GrantEffect } from "@intx/types";
43
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
@@ -8,31 +7,23 @@ import { QueryView, describeApiError, toAPIQuery } from "@/lib/api-query";
87
import { tenantKeys } from "@/query-client";
98
import { GRANT_RESOURCE_LABEL, type GrantResource } from "../settings/resource-vocabulary";
109
import { principalLabel } from "../settings/identity";
11-
import { listGrants, revokeGrant, type Grant } from "../settings/tenancy-api";
10+
import { listGrants, updateGrant, type Grant } from "../settings/tenancy-api";
1211
import { useGrantNames } from "./grant-names";
12+
import "./drawer.css";
1313

14-
const MODE: Record<GrantEffect, string> = {
15-
allow: "Always allow",
16-
ask: "Ask first",
17-
deny: "Deny",
18-
};
14+
const EFFECTS: readonly { readonly effect: GrantEffect; readonly label: string }[] = [
15+
{ effect: "allow", label: "Allow" },
16+
{ effect: "ask", label: "Ask" },
17+
{ effect: "deny", label: "Deny" },
18+
];
1919

2020
const TOOL_PREFIX = "tool:";
2121

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

3829
/** This workbench's own grants; they never inherit from the workspace. */
@@ -50,8 +41,7 @@ export function GrantsTab({
5041
if (known !== undefined) return known.name;
5142
if (grant.principalName === undefined || grant.principalName === null) return "Everyone here";
5243
const named = names.replaceTenantIds(grant.principalName);
53-
// A raw run id is never shown, and never turned into words; with no
54-
// resolvable name the row has no subtitle.
44+
// A raw run id is never shown; with no resolvable name the row has no hint.
5545
return principalLabel(named).raw === null ? named : undefined;
5646
};
5747
const queryClient = useQueryClient();
@@ -61,14 +51,14 @@ export function GrantsTab({
6151
queryFn: () => listGrants(workbenchTenantId, {}),
6252
}),
6353
);
64-
const revoke = useMutation({
65-
mutationFn: (grant: Grant) => revokeGrant(workbenchTenantId, grant.id),
66-
onSuccess: () => {
54+
const setEffect = useMutation({
55+
mutationFn: (input: { readonly grant: Grant; readonly effect: GrantEffect }) =>
56+
updateGrant(workbenchTenantId, input.grant.id, { effect: input.effect }),
57+
onSettled: () => {
6758
void queryClient.invalidateQueries({ queryKey: tenantKeys.grants(workbenchTenantId) });
68-
toast("Grant revoked.");
6959
},
7060
onError: (cause: unknown) => {
71-
toast(describeApiError(cause, "revoking this grant"));
61+
toast(describeApiError(cause, "changing this grant"));
7262
},
7363
});
7464

@@ -85,40 +75,33 @@ export function GrantsTab({
8575
grants.length === 0 ? (
8676
<p className="workbench-info-empty-note">No grants here yet.</p>
8777
) : (
88-
<ul className="bench-tab-list">
78+
<div>
8979
{grants.map((grant) => {
90-
const title = names.resource(grant.resource) ?? resourceName(grant.resource);
91-
const name = who(grant);
92-
const subtitle =
93-
name !== undefined && title.toLowerCase().split(" ").includes(name.toLowerCase())
94-
? undefined
95-
: name;
80+
const label = names.resource(grant.resource) ?? resourceName(grant.resource);
9681
return (
97-
<li key={grant.id} className="bench-tab-row">
98-
<span className="bench-tab-text">
99-
<span className="workbench-info-cell-primary">{title}</span>
100-
{subtitle === undefined ? null : (
101-
<span className="workbench-info-cell-context">{subtitle}</span>
102-
)}
103-
</span>
104-
<span className="drawer-grant-mode" data-effect={grant.effect}>
105-
{MODE[grant.effect]}
106-
</span>
107-
<ConfirmButton
108-
variant="ghost"
109-
size="sm"
110-
disabled={revoke.isPending}
111-
confirmLabel="Revoke?"
112-
onConfirm={() => {
113-
revoke.mutate(grant);
114-
}}
115-
>
116-
Revoke
117-
</ConfirmButton>
118-
</li>
82+
<div key={grant.id} className="drawer-perm" title={who(grant)}>
83+
<b className="drawer-perm-name">{label}</b>
84+
<div className="drawer-seg" role="radiogroup" aria-label={label}>
85+
{EFFECTS.map(({ effect, label: text }) => (
86+
<button
87+
key={effect}
88+
type="button"
89+
role="radio"
90+
aria-checked={effect === grant.effect}
91+
className={effect === grant.effect ? "active" : undefined}
92+
disabled={setEffect.isPending}
93+
onClick={() => {
94+
if (effect !== grant.effect) setEffect.mutate({ grant, effect });
95+
}}
96+
>
97+
{text}
98+
</button>
99+
))}
100+
</div>
101+
</div>
119102
);
120103
})}
121-
</ul>
104+
</div>
122105
)
123106
}
124107
</QueryView>

‎apps/web/src/bench/members-tab.tsx‎

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,74 @@
11
import { RichEmptyState, Skeleton } from "@corbits/react-ui";
22
import { useQuery } from "@tanstack/react-query";
33

4-
import { IdentityAvatar, WorkbenchAvatar } from "@/chat/avatar";
5-
import type { WorkbenchParticipant } from "@/chat/threads-api";
4+
import { WorkbenchAvatar } from "@/chat/avatar";
5+
import { listChatAgents, type WorkbenchParticipant } from "@/chat/threads-api";
6+
import { CaretRight } from "@/lib/icons";
67
import { Link } from "../navigation";
78
import { tenantKeys } from "../query-client";
89
import { principalLabel } from "../settings/identity";
910
import { listPrincipals } from "../settings/tenancy-api";
11+
import { useWorkerRole } from "../worker-role-query";
12+
import { useWorkerStatus } from "../worker-status";
13+
import "./drawer.css";
1014

11-
/** Humans are the bench tenant's user principals; agents are the bench's
12-
* workers, already listed as participants. */
15+
function capitalize(text: string): string {
16+
return text.charAt(0).toUpperCase() + text.slice(1);
17+
}
18+
19+
function WorkerRow({
20+
workbenchTenantId,
21+
participant,
22+
}: {
23+
readonly workbenchTenantId: string;
24+
readonly participant: WorkbenchParticipant;
25+
}) {
26+
const agents = useQuery({
27+
queryKey: tenantKeys.agents(workbenchTenantId),
28+
queryFn: () => listChatAgents(workbenchTenantId),
29+
});
30+
const agent = agents.data?.find((a) => a.id === participant.id);
31+
const status = useWorkerStatus(workbenchTenantId, participant.id);
32+
const body = (
33+
<>
34+
<WorkbenchAvatar kind="worker" name={participant.name} size="md" status={status.tone} />
35+
<span className="drawer-list-text">
36+
<b>{participant.name}</b>
37+
{agent === undefined ? null : (
38+
<WorkerRoleLine workbenchTenantId={workbenchTenantId} agent={agent} />
39+
)}
40+
</span>
41+
<CaretRight size={14} aria-hidden="true" />
42+
</>
43+
);
44+
return (
45+
<li>
46+
<Link className="drawer-list-link" to={`/workers/${encodeURIComponent(participant.id)}`}>
47+
{body}
48+
</Link>
49+
</li>
50+
);
51+
}
52+
53+
function WorkerRoleLine({
54+
workbenchTenantId,
55+
agent,
56+
}: {
57+
readonly workbenchTenantId: string;
58+
readonly agent: Parameters<typeof useWorkerRole>[1];
59+
}) {
60+
return <span>{useWorkerRole(workbenchTenantId, agent)}</span>;
61+
}
62+
63+
/** Humans are the bench tenant's user principals; workers are the bench's
64+
* agents, already listed as participants. */
1365
export function MembersTab({
1466
workbenchTenantId,
1567
participants,
1668
loading,
1769
}: {
1870
readonly workbenchTenantId: string;
19-
/** True until the roster resolves; an empty list then isn't "no agents". */
71+
/** True until the roster resolves; an empty list then isn't "no workers". */
2072
readonly loading: boolean;
2173
readonly participants: readonly WorkbenchParticipant[];
2274
}) {
@@ -26,13 +78,14 @@ export function MembersTab({
2678
queryKey: [...tenantKeys.principals(workbenchTenantId), "members"],
2779
queryFn: async () => (await listPrincipals(workbenchTenantId)).filter((p) => p.kind === "user"),
2880
});
29-
const agents = participants.filter((p) => p.kind === "agent");
81+
const workers = participants.filter((p) => p.kind === "agent");
3082

3183
return (
3284
<div>
3385
<section className="drawer-sec">
3486
<div className="drawer-sec-head">
3587
<h3>People</h3>
88+
<Link to="/settings/people">Invite</Link>
3689
</div>
3790
{people.isPending ? <Skeleton className="h-12 w-full" /> : null}
3891
{people.isError ? (
@@ -47,16 +100,14 @@ export function MembersTab({
47100
) : null}
48101
<ul className="drawer-list">
49102
{(people.data ?? []).map((person) => {
50-
const { label } = principalLabel(person.displayName);
51-
const detail = [person.email, person.roles.map((r) => r.name).join(", ")]
52-
.filter((s): s is string => s !== undefined && s !== "")
53-
.join(" · ");
103+
const name = capitalize(principalLabel(person.displayName).label);
104+
const role = person.roles.map((r) => capitalize(r.name)).join(", ");
54105
return (
55106
<li key={person.id}>
56-
<IdentityAvatar kind="person" name={label} principalId={person.id} />
107+
<WorkbenchAvatar kind="person" name={name} size="md" />
57108
<span className="drawer-list-text">
58-
<b>{label}</b>
59-
<span>{detail}</span>
109+
<b>{name}</b>
110+
{role === "" ? null : <span>{role}</span>}
60111
</span>
61112
</li>
62113
);
@@ -65,39 +116,17 @@ export function MembersTab({
65116
</section>
66117
<section className="drawer-sec">
67118
<div className="drawer-sec-head">
68-
<h3>Agents</h3>
119+
<h3>Workers</h3>
120+
<Link to="/workers">Add</Link>
69121
</div>
70122
{loading ? <Skeleton className="h-12 w-full" /> : null}
71-
{!loading && agents.length === 0 ? (
72-
<p className="workbench-info-empty-note">No agents yet.</p>
123+
{!loading && workers.length === 0 ? (
124+
<p className="workbench-info-empty-note">No workers yet.</p>
73125
) : null}
74126
<ul className="drawer-list">
75-
{agents.map((agent) => {
76-
const workerId = agent.assetName === undefined ? undefined : agent.id;
77-
const body = (
78-
<>
79-
<WorkbenchAvatar kind="worker" name={agent.name} size="md" />
80-
<span className="drawer-list-text">
81-
<b>{agent.name}</b>
82-
<span>{agent.address === "" ? "Starting" : "Running"}</span>
83-
</span>
84-
</>
85-
);
86-
return (
87-
<li key={agent.id}>
88-
{workerId === undefined ? (
89-
body
90-
) : (
91-
<Link
92-
className="drawer-list-link"
93-
to={`/workers/${encodeURIComponent(workerId)}`}
94-
>
95-
{body}
96-
</Link>
97-
)}
98-
</li>
99-
);
100-
})}
127+
{workers.map((worker) => (
128+
<WorkerRow key={worker.id} workbenchTenantId={workbenchTenantId} participant={worker} />
129+
))}
101130
</ul>
102131
</section>
103132
</div>

0 commit comments

Comments
 (0)