feat: redesign auto-approval pill with server-level grouping and batch toggle

Group MCP tools by server and LSP tools by profile in the approval popover
instead of showing a flat list. Each server/profile group gets a collapsible
header with a batch toggle to approve/unapprove all tools at once. Add a
search filter (visible when >10 tools) and partial-state indicators for
groups with mixed approval. Add groupApprovalToolsByProvider shared helper
with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 18:18:17 +01:00
co-authored by Copilot
parent 5ad85db0f5
commit 53a08e0ed4
4 changed files with 352 additions and 53 deletions
+2
View File
@@ -484,6 +484,7 @@ export function ChatPane({
effectiveAutoApprovedCount={effectiveAutoApprovedCount}
isOverridden={isApprovalOverridden}
onUpdate={onUpdateSessionApprovalSettings}
toolingSettings={toolingSettings}
/>
)}
{primaryAgent && (
@@ -539,6 +540,7 @@ export function ChatPane({
effectiveAutoApprovedCount={effectiveAutoApprovedCount}
isOverridden={isApprovalOverridden}
onUpdate={onUpdateSessionApprovalSettings}
toolingSettings={toolingSettings}
/>
)}
</div>
+192 -53
View File
@@ -1,10 +1,11 @@
import { useState } from 'react';
import { ChevronDown, Sparkles } from 'lucide-react';
import { useMemo, useState } from 'react';
import { ChevronDown, ChevronRight, Minus, Search, Sparkles } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { PopoverToggleRow } from '@renderer/components/ui';
import { useClickOutside } from '@renderer/hooks/useClickOutside';
import type { ApprovalToolDefinition, ApprovalToolKind, LspProfileDefinition, McpServerDefinition, SessionToolingSelection } from '@shared/domain/tooling';
import type { ApprovalToolDefinition, LspProfileDefinition, McpServerDefinition, SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
import { groupApprovalToolsByProvider, type ApprovalToolGroup } from '@shared/domain/tooling';
import { findModel, inferProvider, providerMeta, type ModelDefinition } from '@shared/domain/models';
import { reasoningEffortOptions, type ReasoningEffort } from '@shared/domain/pattern';
import { RotateCcw, Server, ShieldCheck } from 'lucide-react';
@@ -337,16 +338,11 @@ function McpServerGroup({
/* ── InlineApprovalPill ────────────────────────────────────── */
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
const approvalKindLabels: Record<ApprovalToolKind, string> = {
builtin: 'Built-in',
mcp: 'MCP Servers',
lsp: 'Language Servers',
mixed: 'Other',
};
const SEARCH_THRESHOLD = 10;
export function InlineApprovalPill({
approvalTools,
toolingSettings,
effectiveAutoApproved,
effectiveAutoApprovedCount,
isOverridden,
@@ -354,6 +350,7 @@ export function InlineApprovalPill({
onUpdate,
}: {
approvalTools: ApprovalToolDefinition[];
toolingSettings: WorkspaceToolingSettings;
effectiveAutoApproved: Set<string>;
effectiveAutoApprovedCount: number;
isOverridden: boolean;
@@ -361,7 +358,32 @@ export function InlineApprovalPill({
onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void;
}){
const [open, setOpen] = useState(false);
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
const [search, setSearch] = useState('');
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
const ref = useClickOutside<HTMLDivElement>(() => { setOpen(false); setSearch(''); }, open);
const groups = useMemo(
() => groupApprovalToolsByProvider(approvalTools, toolingSettings),
[approvalTools, toolingSettings],
);
const showSearch = approvalTools.length > SEARCH_THRESHOLD;
const searchLower = search.toLowerCase().trim();
const filteredGroups = useMemo(() => {
if (!searchLower) return groups;
return groups
.map((group) => ({
...group,
tools: group.tools.filter(
(t) =>
t.label.toLowerCase().includes(searchLower)
|| t.id.toLowerCase().includes(searchLower)
|| group.label.toLowerCase().includes(searchLower),
),
}))
.filter((g) => g.tools.length > 0);
}, [groups, searchLower]);
function toggleTool(toolId: string) {
const next = new Set(effectiveAutoApproved);
@@ -373,10 +395,35 @@ export function InlineApprovalPill({
onUpdate({ autoApprovedToolNames: [...next] });
}
const groups = approvalKindOrder
.map((kind) => ({ kind, tools: approvalTools.filter((t) => t.kind === kind) }))
.filter((g) => g.tools.length > 0);
const showHeaders = groups.length > 1;
function toggleGroup(group: ApprovalToolGroup) {
const allApproved = group.tools.every((t) => effectiveAutoApproved.has(t.id));
const next = new Set(effectiveAutoApproved);
for (const tool of group.tools) {
if (allApproved) {
next.delete(tool.id);
} else {
next.add(tool.id);
}
}
onUpdate({ autoApprovedToolNames: [...next] });
}
function toggleExpanded(groupId: string) {
setExpandedGroups((prev) => {
const next = new Set(prev);
if (next.has(groupId)) {
next.delete(groupId);
} else {
next.add(groupId);
}
return next;
});
}
function isGroupExpanded(groupId: string): boolean {
if (searchLower) return true;
return expandedGroups.has(groupId);
}
return (
<div className="relative" ref={ref}>
@@ -400,52 +447,144 @@ export function InlineApprovalPill({
</button>
{open && !disabled && (
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-80 w-72 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 shadow-2xl">
<div className="flex items-center gap-2 border-b border-zinc-800 px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
isOverridden
? 'bg-amber-500/15 text-amber-400'
: 'bg-zinc-800 text-zinc-500'
}`}>
{isOverridden ? 'Session override' : 'Pattern defaults'}
</span>
{isOverridden && (
<button
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={() => onUpdate({})}
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 shadow-2xl">
{/* Header: session override / pattern defaults */}
<div className="sticky top-0 z-10 border-b border-zinc-800 bg-zinc-900">
<div className="flex items-center gap-2 px-3 py-2">
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
isOverridden
? 'bg-amber-500/15 text-amber-400'
: 'bg-zinc-800 text-zinc-500'
}`}>
{isOverridden ? 'Session override' : 'Pattern defaults'}
</span>
{isOverridden && (
<button
className="flex items-center gap-1 rounded-full px-2 py-0.5 text-[9px] font-medium text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
onClick={() => onUpdate({})}
type="button"
>
<RotateCcw className="size-2.5" />
Reset
</button>
)}
</div>
{/* Search */}
{showSearch && (
<div className="border-t border-zinc-800/50 px-3 py-1.5">
<div className="flex items-center gap-2 rounded border border-zinc-800 bg-zinc-800/30 px-2 py-1">
<Search className="size-3 shrink-0 text-zinc-600" />
<input
autoFocus
className="w-full bg-transparent text-[12px] text-zinc-300 placeholder-zinc-600 outline-none"
onChange={(e) => setSearch(e.target.value)}
placeholder="Filter tools…"
type="text"
value={search}
/>
</div>
</div>
)}
</div>
{/* Tool groups */}
<div className="py-1">
{groups.map((group, i) => (
<div key={group.kind}>
{showHeaders && (
<div className={`px-3 pb-1 ${i > 0 ? 'pt-2' : 'pt-1'} text-[9px] font-semibold uppercase tracking-wider text-zinc-600`}>
{approvalKindLabels[group.kind]}
</div>
)}
{group.tools.map((tool) => {
const detail = tool.description || (tool.providerNames.length > 0 ? tool.providerNames.join(', ') : undefined);
return (
<PopoverToggleRow
detail={detail}
enabled={effectiveAutoApproved.has(tool.id)}
key={tool.id}
label={tool.label}
onToggle={() => toggleTool(tool.id)}
/>
);
})}
{filteredGroups.map((group, groupIdx) => {
const isBuiltin = group.kind === 'builtin';
const isCollapsible = !isBuiltin;
const expanded = isBuiltin || isGroupExpanded(group.id);
const approvedCount = group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
const allApproved = approvedCount === group.tools.length;
const someApproved = approvedCount > 0 && !allApproved;
return (
<div key={group.id}>
{/* Group header */}
{isBuiltin ? (
<div className={`px-3 pb-1 ${groupIdx > 0 ? 'pt-2.5' : 'pt-1'} text-[9px] font-semibold uppercase tracking-wider text-zinc-600`}>
{group.label}
</div>
) : (
<button
className={`flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left transition hover:bg-zinc-800/60 ${groupIdx > 0 ? 'mt-0.5' : ''}`}
onClick={() => toggleExpanded(group.id)}
type="button"
>
<ChevronRight className={`size-3 shrink-0 text-zinc-600 transition ${expanded ? 'rotate-90' : ''}`} />
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-zinc-300">{group.label}</span>
<span className="shrink-0 rounded-full bg-zinc-800/80 px-1.5 py-px text-[9px] font-medium tabular-nums text-zinc-500">
{approvedCount}/{group.tools.length}
</span>
<GroupToggle
allApproved={allApproved}
someApproved={someApproved}
onToggle={(e) => { e.stopPropagation(); toggleGroup(group); }}
/>
</button>
)}
{/* Group tools */}
{expanded && group.tools.map((tool) => {
const detail = tool.description || (
!isBuiltin && tool.providerNames.length > 1
? tool.providerNames.join(', ')
: undefined
);
return (
<div key={tool.id} className={isCollapsible ? 'pl-3' : ''}>
<PopoverToggleRow
detail={detail}
enabled={effectiveAutoApproved.has(tool.id)}
label={tool.label}
onToggle={() => toggleTool(tool.id)}
/>
</div>
);
})}
</div>
);
})}
{filteredGroups.length === 0 && searchLower && (
<div className="px-3 py-4 text-center text-[12px] text-zinc-600">
No tools match "{search}"
</div>
))}
)}
</div>
</div>
)}
</div>
);
}
function GroupToggle({
allApproved,
someApproved,
onToggle,
}: {
allApproved: boolean;
someApproved: boolean;
onToggle: (e: React.MouseEvent) => void;
}) {
return (
<button
aria-pressed={allApproved}
className={`relative inline-flex h-[16px] w-[28px] shrink-0 items-center rounded-full transition-colors ${
allApproved ? 'bg-indigo-500' : someApproved ? 'bg-zinc-600' : 'bg-zinc-700'
}`}
onClick={onToggle}
type="button"
>
{someApproved ? (
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-zinc-300" strokeWidth={3} />
) : (
<span
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
}`}
/>
)}
</button>
);
}
+63
View File
@@ -265,6 +265,69 @@ export function listApprovalToolNames(
return listApprovalToolDefinitions(tooling, runtimeTools).map((tool) => tool.id);
}
export interface ApprovalToolGroup {
id: string;
label: string;
kind: ApprovalToolKind;
tools: ApprovalToolDefinition[];
}
const approvalToolGroupKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
export function groupApprovalToolsByProvider(
tools: ReadonlyArray<ApprovalToolDefinition>,
tooling: WorkspaceToolingSettings,
): ApprovalToolGroup[] {
const serverNames = new Map(tooling.mcpServers.map((s) => [s.id, s.name]));
const profileNames = new Map(tooling.lspProfiles.map((p) => [p.id, p.name]));
const groups = new Map<string, ApprovalToolGroup>();
for (const tool of tools) {
const groupKey = resolveApprovalToolGroupKey(tool, serverNames, profileNames);
let group = groups.get(groupKey.id);
if (!group) {
group = { id: groupKey.id, label: groupKey.label, kind: groupKey.kind, tools: [] };
groups.set(groupKey.id, group);
}
group.tools.push(tool);
}
return [...groups.values()].sort((a, b) => {
const kindDiff = approvalToolGroupKindOrder.indexOf(a.kind) - approvalToolGroupKindOrder.indexOf(b.kind);
if (kindDiff !== 0) return kindDiff;
return a.label.localeCompare(b.label);
});
}
function resolveApprovalToolGroupKey(
tool: ApprovalToolDefinition,
serverNames: ReadonlyMap<string, string>,
profileNames: ReadonlyMap<string, string>,
): { id: string; label: string; kind: ApprovalToolKind } {
if (tool.kind === 'builtin') {
return { id: 'builtin', label: 'Built-in', kind: 'builtin' };
}
const primaryProviderId = tool.providerIds[0];
if (tool.kind === 'mcp' && primaryProviderId) {
return {
id: `mcp:${primaryProviderId}`,
label: serverNames.get(primaryProviderId) ?? tool.providerNames[0] ?? primaryProviderId,
kind: 'mcp',
};
}
if (tool.kind === 'lsp' && primaryProviderId) {
return {
id: `lsp:${primaryProviderId}`,
label: profileNames.get(primaryProviderId) ?? tool.providerNames[0] ?? primaryProviderId,
kind: 'lsp',
};
}
return { id: 'other', label: 'Other', kind: 'mixed' };
}
export function validateMcpServerDefinition(server: McpServerDefinition): string | undefined {
if (!server.name.trim()) {
return 'MCP server name is required.';
+95
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from 'bun:test';
import {
groupApprovalToolsByProvider,
listApprovalToolDefinitions,
normalizeWorkspaceSettings,
resolveProjectToolingSettings,
@@ -10,6 +11,7 @@ import {
validateMcpServerDefinition,
type LspProfileDefinition,
type McpServerDefinition,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
const TIMESTAMP = '2026-03-23T00:00:00.000Z';
@@ -378,3 +380,96 @@ describe('tooling settings helpers', () => {
});
});
});
describe('groupApprovalToolsByProvider', () => {
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
function makeTooling(
mcpServers: McpServerDefinition[] = [],
lspProfiles: LspProfileDefinition[] = [],
): WorkspaceToolingSettings {
return { mcpServers, lspProfiles };
}
function makeMcpServer(id: string, name: string, tools: string[]): McpServerDefinition {
return { id, name, transport: 'local', command: 'node', args: [], tools, createdAt: TIMESTAMP, updatedAt: TIMESTAMP };
}
function makeLspProfile(id: string, name: string): LspProfileDefinition {
return { id, name, command: 'lsp-server', args: ['--stdio'], languageId: 'typescript', fileExtensions: ['.ts'], createdAt: TIMESTAMP, updatedAt: TIMESTAMP };
}
test('groups MCP tools by server', () => {
const tooling = makeTooling([
makeMcpServer('git', 'Git MCP', ['git.status', 'git.diff']),
makeMcpServer('fs', 'Filesystem', ['fs.read', 'fs.write']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
expect(mcpGroups.length).toBe(2);
const fsGroup = mcpGroups.find((g) => g.label === 'Filesystem');
expect(fsGroup).toBeDefined();
expect(fsGroup!.tools.map((t) => t.id).sort()).toEqual(['fs.read', 'fs.write']);
const gitGroup = mcpGroups.find((g) => g.label === 'Git MCP');
expect(gitGroup).toBeDefined();
expect(gitGroup!.tools.map((t) => t.id).sort()).toEqual(['git.diff', 'git.status']);
});
test('groups LSP tools by profile', () => {
const tooling = makeTooling([], [makeLspProfile('ts', 'TypeScript')]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const lspGroups = groups.filter((g) => g.kind === 'lsp');
expect(lspGroups.length).toBe(1);
expect(lspGroups[0].label).toBe('TypeScript');
expect(lspGroups[0].tools.length).toBe(5);
});
test('keeps builtins in one group', () => {
const tooling = makeTooling();
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const builtinGroups = groups.filter((g) => g.kind === 'builtin');
expect(builtinGroups.length).toBe(1);
expect(builtinGroups[0].label).toBe('Built-in');
expect(builtinGroups[0].tools.length).toBe(5);
});
test('multi-provider tools go into first provider group', () => {
const tooling = makeTooling([
makeMcpServer('git-a', 'Git A', ['git.status']),
makeMcpServer('git-b', 'Git B', ['git.status']),
]);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const mcpGroups = groups.filter((g) => g.kind === 'mcp');
expect(mcpGroups.length).toBe(1);
expect(mcpGroups[0].label).toBe('Git A');
expect(mcpGroups[0].tools[0].providerNames).toEqual(['Git A', 'Git B']);
});
test('sorts builtin first, then MCP by name, then LSP by name', () => {
const tooling = makeTooling(
[makeMcpServer('z', 'Zebra', ['z.tool']), makeMcpServer('a', 'Alpha', ['a.tool'])],
[makeLspProfile('ts', 'TypeScript')],
);
const tools = listApprovalToolDefinitions(tooling);
const groups = groupApprovalToolsByProvider(tools, tooling);
const kindOrder = groups.map((g) => g.kind);
expect(kindOrder[0]).toBe('builtin');
const mcpIdx = kindOrder.indexOf('mcp');
const lspIdx = kindOrder.indexOf('lsp');
expect(mcpIdx).toBeLessThan(lspIdx);
const mcpLabels = groups.filter((g) => g.kind === 'mcp').map((g) => g.label);
expect(mcpLabels).toEqual(['Alpha', 'Zebra']);
});
});