Skip to content

Commit 71889a2

Browse files
committed
refactor(ui/connectors): unified toggle card for all agents, auto-save on toggle
Replaces the two-tier granted-row / pending-card split with a single AgentGrantCard component shared by all agents (whether already granted or not). Granted scopes start ON; ungranted scopes start OFF. Toggling any switch fires the API immediately — no "Grant selected" button. Remove useMemo, X, and btn-grant-revoke/confirm dead code.
1 parent 13c64c7 commit 71889a2

2 files changed

Lines changed: 65 additions & 189 deletions

File tree

src/gaia/apps/webui/src/components/ConnectorsSection.css

Lines changed: 9 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -276,60 +276,22 @@
276276
font-style: italic;
277277
}
278278

279-
/* Granted agent rows — use a grid so the name column is fixed-width
280-
and the scopes column aligns consistently regardless of name length. */
281-
.grant-row {
282-
display: grid;
283-
grid-template-columns: 9rem 1fr auto;
284-
align-items: baseline;
285-
gap: 8px;
286-
padding: 5px 0;
287-
font-size: 12px;
288-
font-family: var(--font-sans);
289-
}
290-
291-
.grant-agent {
292-
font-weight: 500;
293-
color: var(--text-primary);
294-
white-space: nowrap;
295-
overflow: hidden;
296-
text-overflow: ellipsis;
297-
}
298-
299-
.grant-scopes {
300-
color: var(--text-secondary);
301-
line-height: 1.4;
302-
}
303-
304-
.btn-grant-revoke {
305-
background: none;
306-
border: none;
307-
cursor: pointer;
308-
color: var(--text-muted);
309-
padding: 2px;
310-
border-radius: 3px;
311-
display: flex;
312-
align-items: center;
313-
transition: color var(--duration) var(--ease);
314-
flex-shrink: 0;
315-
}
316-
.btn-grant-revoke:hover { color: var(--accent-red, #e55); }
279+
/* ── Unified agent grant card ─────────────────────────────────── */
317280

318-
/* ── Pending-agent scope-picker card ─────────────────────────── */
319-
320-
.grant-pending-card {
281+
/* One card per agent — same layout whether granted or pending. */
282+
.grant-agent-card {
321283
border: 1px solid var(--border-light);
322284
border-radius: var(--radius-sm);
323285
padding: 10px 14px;
324286
margin-top: 6px;
325287
background: var(--bg-secondary);
326288
}
327289

328-
.grant-pending-header {
329-
display: flex;
330-
align-items: center;
331-
justify-content: space-between;
332-
gap: 8px;
290+
.grant-agent-card-name {
291+
font-size: 12px;
292+
font-weight: 600;
293+
font-family: var(--font-sans);
294+
color: var(--text-primary);
333295
margin-bottom: 10px;
334296
}
335297

@@ -346,7 +308,7 @@
346308
align-items: center;
347309
justify-content: space-between;
348310
gap: 12px;
349-
padding: 4px 0;
311+
padding: 5px 0;
350312
cursor: pointer;
351313
user-select: none;
352314
}
@@ -422,25 +384,6 @@
422384
}
423385
.grant-scope-warning--error { color: var(--accent-red, #e55); }
424386

425-
.btn-grant-confirm {
426-
display: inline-flex;
427-
align-items: center;
428-
gap: 5px;
429-
padding: 4px 10px;
430-
font-size: 11px;
431-
font-family: var(--font-sans);
432-
font-weight: 500;
433-
background: var(--accent-blue, #4e9de0);
434-
color: #fff;
435-
border: none;
436-
border-radius: var(--radius-sm);
437-
cursor: pointer;
438-
white-space: nowrap;
439-
transition: opacity var(--duration) var(--ease);
440-
}
441-
.btn-grant-confirm:hover { opacity: 0.85; }
442-
.btn-grant-confirm:disabled { opacity: 0.45; cursor: not-allowed; }
443-
444387
/* .spin / @keyframes spin are defined globally in src/styles/index.css */
445388

446389
/* ── Shared ───────────────────────────────────────────────────── */

src/gaia/apps/webui/src/components/ConnectorsSection.tsx

Lines changed: 56 additions & 123 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99
* shows an OAuth or MCP-key configure form plus per-agent grants.
1010
*/
1111

12-
import { useEffect, useState, useCallback, useMemo } from 'react';
12+
import { useEffect, useState, useCallback } from 'react';
1313
import {
1414
CheckCircle2,
1515
AlertCircle,
1616
Loader2,
1717
ExternalLink,
1818
ChevronDown,
1919
ChevronUp,
20-
X,
2120
} from 'lucide-react';
2221

2322
// Human-readable labels for well-known OAuth scope URIs.
@@ -511,83 +510,76 @@ function MCPServerConfigureBody({
511510

512511
// ── ConnectorAgentGrants ─────────────────────────────────────────────────────
513512

514-
/** Inline scope-picker for a single pending agent. */
515-
function PendingAgentRow({
513+
/**
514+
* Unified scope-toggle card for one agent (granted or not).
515+
* Toggling a scope auto-saves immediately — no explicit button needed.
516+
* Granted scopes start ON; not-yet-granted scopes start OFF.
517+
*/
518+
function AgentGrantCard({
516519
agent,
517520
connectorId,
518-
allScopes,
519-
reason,
520-
onGranted,
521+
grantedScopes,
522+
onChanged,
521523
}: {
522-
agent: { namespaced_agent_id?: string; name: string };
524+
agent: { namespaced_agent_id?: string; name: string; required_connections?: Array<{ connector_id: string; scopes: string[]; reason: string }> };
523525
connectorId: string;
524-
allScopes: string[];
525-
reason: string;
526-
onGranted: () => void;
526+
grantedScopes: string[];
527+
onChanged: () => void;
527528
}) {
528-
const [selected, setSelected] = useState<Set<string>>(() => new Set(allScopes));
529-
const [busy, setBusy] = useState(false);
530-
const [err, setErr] = useState<string | null>(null);
529+
const req = agent.required_connections?.find((rc) => rc.connector_id === connectorId);
530+
const agentId = agent.namespaced_agent_id;
531+
if (!req || !agentId) return null;
531532

532-
const toggle = (scope: string) =>
533-
setSelected((prev) => {
534-
const next = new Set(prev);
535-
next.has(scope) ? next.delete(scope) : next.add(scope);
536-
return next;
537-
});
533+
const [localScopes, setLocalScopes] = useState<Set<string>>(() => new Set(grantedScopes));
534+
const [busyScope, setBusyScope] = useState<string | null>(null);
535+
const [err, setErr] = useState<string | null>(null);
538536

539-
const grant = async () => {
540-
const agentId = agent.namespaced_agent_id;
541-
if (!agentId || selected.size === 0) return;
542-
setBusy(true);
537+
// Sync when the parent reloads grants after a successful API call.
538+
useEffect(() => {
539+
setLocalScopes(new Set(grantedScopes));
540+
}, [grantedScopes.join(',')]); // eslint-disable-line react-hooks/exhaustive-deps
541+
542+
const toggleScope = async (scope: string) => {
543+
if (busyScope !== null) return; // one request at a time
544+
const next = new Set(localScopes);
545+
next.has(scope) ? next.delete(scope) : next.add(scope);
546+
setLocalScopes(next); // optimistic
547+
setBusyScope(scope);
543548
setErr(null);
544549
try {
545-
await api.grantConnectorAgent(connectorId, agentId, [...selected]);
546-
onGranted();
550+
if (next.size === 0) {
551+
await api.revokeConnectorAgentGrant(connectorId, agentId);
552+
} else {
553+
await api.grantConnectorAgent(connectorId, agentId, [...next]);
554+
}
555+
onChanged();
547556
} catch (e) {
557+
setLocalScopes(new Set(grantedScopes)); // revert
548558
setErr(e instanceof Error ? e.message : String(e));
549559
} finally {
550-
setBusy(false);
560+
setBusyScope(null);
551561
}
552562
};
553563

554-
const missingRequired = allScopes.some((s) => !selected.has(s));
555-
556564
return (
557-
<div className="grant-pending-card">
558-
<div className="grant-pending-header">
559-
<span className="grant-agent">{agent.name}</span>
560-
<button
561-
className="btn-grant-confirm"
562-
disabled={busy || selected.size === 0}
563-
onClick={() => void grant()}
564-
title={reason}
565-
>
566-
{busy ? <Loader2 size={11} className="spin" /> : null}
567-
Grant selected
568-
</button>
569-
</div>
565+
<div className="grant-agent-card">
566+
<div className="grant-agent-card-name">{agent.name}</div>
570567
<div className="grant-scope-list">
571-
{allScopes.map((scope) => (
568+
{req.scopes.map((scope) => (
572569
<label key={scope} className="grant-scope-item">
573570
<span className="grant-scope-label">{scopeLabel(scope)}</span>
574571
<span className="toggle-switch">
575572
<input
576573
type="checkbox"
577-
checked={selected.has(scope)}
578-
onChange={() => toggle(scope)}
574+
checked={localScopes.has(scope)}
575+
onChange={() => void toggleScope(scope)}
576+
disabled={busyScope !== null}
579577
/>
580578
<span className="toggle-track" />
581579
</span>
582580
</label>
583581
))}
584582
</div>
585-
{missingRequired && (
586-
<div className="grant-scope-warning">
587-
<AlertCircle size={11} />
588-
This agent may not work correctly without all scopes.
589-
</div>
590-
)}
591583
{err && (
592584
<div className="grant-scope-warning grant-scope-warning--error">
593585
<AlertCircle size={11} /> {err}
@@ -601,8 +593,6 @@ function ConnectorAgentGrants({ connectorId }: { connectorId: string }) {
601593
const { agents } = useChatStore();
602594
const [grants, setGrants] = useState<Record<string, string[]>>({});
603595
const [loading, setLoading] = useState(true);
604-
const [revoking, setRevoking] = useState<string | null>(null);
605-
const [actionErr, setActionErr] = useState<string | null>(null);
606596

607597
const load = useCallback(async () => {
608598
try {
@@ -617,86 +607,29 @@ function ConnectorAgentGrants({ connectorId }: { connectorId: string }) {
617607

618608
useEffect(() => { void load(); }, [load]);
619609

620-
const revoke = async (agentId: string) => {
621-
setRevoking(agentId);
622-
setActionErr(null);
623-
try {
624-
await api.revokeConnectorAgentGrant(connectorId, agentId);
625-
void load();
626-
} catch (e) {
627-
setActionErr(e instanceof Error ? e.message : String(e));
628-
} finally {
629-
setRevoking(null);
630-
}
631-
};
632-
633-
// Agents that declare a requirement for this connector but have no grant yet.
634-
const pendingAgents = useMemo(() =>
635-
agents.filter((a) => {
636-
if (!a.namespaced_agent_id) return false;
637-
if (a.namespaced_agent_id in grants) return false;
638-
return a.required_connections?.some((rc) => rc.connector_id === connectorId) ?? false;
639-
}),
640-
[agents, grants, connectorId],
641-
);
642-
643610
if (loading) return null;
644611

645-
const grantedEntries = Object.entries(grants);
646-
const hasAnything = grantedEntries.length > 0 || pendingAgents.length > 0;
612+
// Every agent that declares a requirement for this connector — granted or not.
613+
const relevantAgents = agents.filter(
614+
(a) => a.namespaced_agent_id && a.required_connections?.some((rc) => rc.connector_id === connectorId),
615+
);
647616

648617
return (
649618
<div className="connection-grants">
650619
<div className="grants-header">Per-agent grants</div>
651-
{actionErr && (
652-
<div className="configure-error" style={{ marginBottom: 6 }}>
653-
<AlertCircle size={12} /> {actionErr}
654-
</div>
655-
)}
656-
{!hasAnything && (
657-
<div className="grants-empty">No agents have been granted access yet.</div>
658-
)}
659-
660-
{/* Granted agents — compact rows */}
661-
{grantedEntries.map(([agentId, scopes]) => {
662-
const agent = agents.find((a) => a.namespaced_agent_id === agentId);
663-
return (
664-
<div key={agentId} className="grant-row">
665-
<span className="grant-agent">{agent ? agent.name : agentId}</span>
666-
<span className="grant-scopes">
667-
{scopes.map(scopeLabel).join(', ')}
668-
</span>
669-
<button
670-
className="btn-grant-revoke"
671-
disabled={revoking === agentId}
672-
onClick={() => void revoke(agentId)}
673-
aria-label={`Revoke ${agentId}`}
674-
>
675-
{revoking === agentId
676-
? <Loader2 size={11} className="spin" />
677-
: <X size={11} />}
678-
</button>
679-
</div>
680-
);
681-
})}
682-
683-
{/* Pending agents — scope-picker cards */}
684-
{pendingAgents.map((agent) => {
685-
const req = agent.required_connections?.find(
686-
(rc) => rc.connector_id === connectorId,
687-
);
688-
if (!req) return null;
689-
return (
690-
<PendingAgentRow
620+
{relevantAgents.length === 0 ? (
621+
<div className="grants-empty">No agents require access to this connector.</div>
622+
) : (
623+
relevantAgents.map((agent) => (
624+
<AgentGrantCard
691625
key={agent.namespaced_agent_id}
692626
agent={agent}
693627
connectorId={connectorId}
694-
allScopes={req.scopes}
695-
reason={req.reason}
696-
onGranted={() => void load()}
628+
grantedScopes={grants[agent.namespaced_agent_id!] ?? []}
629+
onChanged={() => void load()}
697630
/>
698-
);
699-
})}
631+
))
632+
)}
700633
</div>
701634
);
702635
}

0 commit comments

Comments
 (0)