mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 11:48:46 +02:00
Complete visual overhaul of the Aryx application with the Luminous Depth design language — blue-tinted dark surfaces, brand gradient accents, glass-morphism effects, and refined typography. Design Foundation: - New color system with CSS custom properties for all surfaces, borders, text, accents, and status colors - Typography: Outfit (display), DM Sans (body), JetBrains Mono (code) - Animations: accent-flow, thinking-wave, ambient-glow - Utility classes: glass-surface, glow-border, brand-gradient-bg/text - Light theme with cool-tinted whites and blue-tinted shadows Components Updated: - UI primitives (TextInput, SelectInput, ToggleSwitch, FormField, etc.) - AppShell with ambient glow background - Sidebar with brand gradient selection and accent-flow running bars - ChatPane with semantic phase tinting and gradient user avatars - WelcomePane with nebula background and motion entrance animations - All chat banners (Approval, UserInput, PlanReview, MCP Auth, etc.) - ActivityPanel and RunTimeline with glass-surface cards - Settings panels, modals, and editor shells - TerminalPanel with harmonized ANSI palette - PatternGraph nodes with glass-surface styling - MarkdownComposer toolbar Zero hardcoded zinc/indigo color classes remain in renderer components. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
695 lines
28 KiB
TypeScript
695 lines
28 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { ChevronDown, ChevronRight, Loader2, Minus, Search, Sparkles, TerminalSquare } from 'lucide-react';
|
|
|
|
import { ProviderIcon } from '@renderer/components/ProviderIcons';
|
|
import { PopoverToggleRow } from '@renderer/components/ui';
|
|
import { useClickOutside } from '@renderer/hooks/useClickOutside';
|
|
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';
|
|
|
|
/* ── Tier badge ────────────────────────────────────────────── */
|
|
|
|
function TierBadge({ tier }: { tier: ModelDefinition['tier'] }) {
|
|
if (!tier) return null;
|
|
const styles = {
|
|
premium: 'bg-[var(--color-status-warning)]/10 text-[var(--color-status-warning)]',
|
|
standard: 'bg-[var(--color-surface-3)]/50 text-[var(--color-text-muted)]',
|
|
fast: 'bg-[var(--color-status-success)]/10 text-[var(--color-status-success)]',
|
|
};
|
|
return (
|
|
<span className={`ml-auto rounded px-1.5 py-0.5 text-[9px] font-medium ${styles[tier]}`}>
|
|
{tier}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
/* ── Helpers ────────────────────────────────────────────────── */
|
|
|
|
function toggleInArray(current: string[], id: string): string[] {
|
|
return current.includes(id)
|
|
? current.filter((currentId) => currentId !== id)
|
|
: [...current, id];
|
|
}
|
|
|
|
/* ── InlineModelPill ───────────────────────────────────────── */
|
|
|
|
export function InlineModelPill({
|
|
value,
|
|
models,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
value: string;
|
|
models: ReadonlyArray<ModelDefinition>;
|
|
onChange: (model: string) => void;
|
|
disabled: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
|
|
|
|
const selected = findModel(value, models);
|
|
const provider = selected?.provider ?? inferProvider(value);
|
|
const displayName = selected?.name ?? value ?? 'Model';
|
|
|
|
const groupedModels = providerMeta
|
|
.map((pg) => ({ ...pg, models: models.filter((m) => m.provider === pg.id) }))
|
|
.filter((pg) => pg.models.length > 0);
|
|
const otherModels = models.filter((m) => !m.provider);
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-pill font-medium transition-all duration-200 ${
|
|
open
|
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
|
: 'border-[var(--color-border-subtle)] bg-[var(--color-surface-2)]/40 text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:text-[var(--color-text-primary)]'
|
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
disabled={disabled}
|
|
onClick={() => setOpen(!open)}
|
|
type="button"
|
|
>
|
|
{provider && <ProviderIcon provider={provider} className="size-2.5" />}
|
|
<span className="max-w-[100px] truncate">{displayName}</span>
|
|
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{open && !disabled && (
|
|
<div className="absolute bottom-full right-0 z-40 mb-1.5 max-h-72 w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl" role="listbox">
|
|
{groupedModels.map((pg) => (
|
|
<div key={pg.id}>
|
|
<div className="flex items-center gap-2 px-3 pb-1 pt-2.5">
|
|
<ProviderIcon provider={pg.id} className="size-3.5" />
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
{pg.label}
|
|
</span>
|
|
</div>
|
|
{pg.models.map((model) => (
|
|
<button
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition-all duration-200 hover:bg-[var(--color-surface-3)] ${
|
|
model.id === value ? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]' : 'text-[var(--color-text-primary)]'
|
|
}`}
|
|
key={model.id}
|
|
onClick={() => { onChange(model.id); setOpen(false); }}
|
|
role="option"
|
|
aria-selected={model.id === value}
|
|
type="button"
|
|
>
|
|
<span className="flex-1">{model.name}</span>
|
|
<TierBadge tier={model.tier} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
))}
|
|
{otherModels.length > 0 && (
|
|
<div>
|
|
<div className="px-3 pb-1 pt-2.5 text-[10px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
Other
|
|
</div>
|
|
{otherModels.map((model) => (
|
|
<button
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition-all duration-200 hover:bg-[var(--color-surface-3)] ${
|
|
model.id === value ? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]' : 'text-[var(--color-text-primary)]'
|
|
}`}
|
|
key={model.id}
|
|
onClick={() => { onChange(model.id); setOpen(false); }}
|
|
role="option"
|
|
aria-selected={model.id === value}
|
|
type="button"
|
|
>
|
|
<span className="flex-1">{model.name}</span>
|
|
<TierBadge tier={model.tier} />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── InlineThinkingPill ────────────────────────────────────── */
|
|
|
|
export function InlineThinkingPill({
|
|
value,
|
|
supportedEfforts,
|
|
onChange,
|
|
disabled,
|
|
}: {
|
|
value?: ReasoningEffort;
|
|
supportedEfforts?: ReadonlyArray<ReasoningEffort>;
|
|
onChange: (effort: ReasoningEffort) => void;
|
|
disabled: boolean;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
|
|
|
|
const options = supportedEfforts
|
|
? reasoningEffortOptions.filter((o) => supportedEfforts.includes(o.value))
|
|
: [...reasoningEffortOptions];
|
|
|
|
if (supportedEfforts && supportedEfforts.length === 0) {
|
|
return (
|
|
<span className="inline-flex items-center gap-1 rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)]/20 px-1.5 py-0.5 text-pill text-[var(--color-text-muted)]">
|
|
<Sparkles className="size-2.5" />
|
|
N/A
|
|
</span>
|
|
);
|
|
}
|
|
|
|
const currentLabel = options.find((o) => o.value === value)?.label ?? value ?? 'Thinking';
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-pill font-medium transition-all duration-200 ${
|
|
open
|
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
|
: 'border-[var(--color-border-subtle)] bg-[var(--color-surface-2)]/40 text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:text-[var(--color-text-primary)]'
|
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
disabled={disabled}
|
|
onClick={() => setOpen(!open)}
|
|
type="button"
|
|
>
|
|
<Sparkles className="size-2.5" />
|
|
<span>{currentLabel}</span>
|
|
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{open && !disabled && (
|
|
<div className="absolute bottom-full right-0 z-40 mb-1.5 w-36 overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl" role="listbox">
|
|
{options.map((option) => (
|
|
<button
|
|
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition-all duration-200 hover:bg-[var(--color-surface-3)] ${
|
|
option.value === value ? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]' : 'text-[var(--color-text-primary)]'
|
|
}`}
|
|
key={option.value}
|
|
onClick={() => { onChange(option.value); setOpen(false); }}
|
|
role="option"
|
|
aria-selected={option.value === value}
|
|
type="button"
|
|
>
|
|
{option.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── InlineToolsPill ───────────────────────────────────────── */
|
|
|
|
export function InlineToolsPill({
|
|
mcpServers,
|
|
lspProfiles,
|
|
selection,
|
|
disabled,
|
|
onToggle,
|
|
}: {
|
|
mcpServers: ReadonlyArray<McpServerDefinition>;
|
|
lspProfiles: ReadonlyArray<LspProfileDefinition>;
|
|
selection: SessionToolingSelection;
|
|
disabled: boolean;
|
|
onToggle: (selection: SessionToolingSelection) => void;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
|
|
|
|
const enabledCount = selection.enabledMcpServerIds.length + selection.enabledLspProfileIds.length;
|
|
const totalCount = mcpServers.length + lspProfiles.length;
|
|
|
|
const workspaceMcpServers = mcpServers.filter((s) => !s.id.startsWith('discovered_'));
|
|
const discoveredUserMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_user_'));
|
|
const discoveredProjectMcpServers = mcpServers.filter((s) => s.id.startsWith('discovered_project_'));
|
|
|
|
return (
|
|
<div className="relative" ref={ref}>
|
|
<button
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-pill font-medium transition-all duration-200 ${
|
|
open
|
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
|
: 'border-[var(--color-border-subtle)] bg-[var(--color-surface-2)]/40 text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:text-[var(--color-text-primary)]'
|
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
disabled={disabled}
|
|
onClick={() => setOpen(!open)}
|
|
type="button"
|
|
>
|
|
<Server className="size-2.5" />
|
|
<span>{enabledCount}/{totalCount} tools</span>
|
|
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{open && !disabled && (
|
|
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-64 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] py-1 shadow-2xl">
|
|
{workspaceMcpServers.length > 0 && (
|
|
<McpServerGroup
|
|
label="Workspace MCP"
|
|
onToggle={onToggle}
|
|
selection={selection}
|
|
servers={workspaceMcpServers}
|
|
/>
|
|
)}
|
|
{discoveredUserMcpServers.length > 0 && (
|
|
<McpServerGroup
|
|
label="User MCP"
|
|
onToggle={onToggle}
|
|
selection={selection}
|
|
servers={discoveredUserMcpServers}
|
|
/>
|
|
)}
|
|
{discoveredProjectMcpServers.length > 0 && (
|
|
<McpServerGroup
|
|
label="Project MCP"
|
|
onToggle={onToggle}
|
|
selection={selection}
|
|
servers={discoveredProjectMcpServers}
|
|
/>
|
|
)}
|
|
{lspProfiles.length > 0 && (
|
|
<div>
|
|
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
Language Servers
|
|
</div>
|
|
{lspProfiles.map((profile) => (
|
|
<PopoverToggleRow
|
|
detail={profile.command}
|
|
enabled={selection.enabledLspProfileIds.includes(profile.id)}
|
|
key={profile.id}
|
|
label={profile.name}
|
|
onToggle={() =>
|
|
onToggle({
|
|
...selection,
|
|
enabledLspProfileIds: toggleInArray(selection.enabledLspProfileIds, profile.id),
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function McpServerGroup({
|
|
label,
|
|
servers,
|
|
selection,
|
|
onToggle,
|
|
}: {
|
|
label: string;
|
|
servers: ReadonlyArray<McpServerDefinition>;
|
|
selection: SessionToolingSelection;
|
|
onToggle: (selection: SessionToolingSelection) => void;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-[var(--color-text-muted)]">
|
|
{label}
|
|
</div>
|
|
{servers.map((server) => (
|
|
<PopoverToggleRow
|
|
detail={server.transport === 'local' ? server.command : server.url}
|
|
enabled={selection.enabledMcpServerIds.includes(server.id)}
|
|
key={server.id}
|
|
label={server.name}
|
|
onToggle={() =>
|
|
onToggle({
|
|
...selection,
|
|
enabledMcpServerIds: toggleInArray(selection.enabledMcpServerIds, server.id),
|
|
})
|
|
}
|
|
/>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── InlineApprovalPill ────────────────────────────────────── */
|
|
|
|
const SEARCH_THRESHOLD = 10;
|
|
|
|
export function InlineApprovalPill({
|
|
approvalTools,
|
|
toolingSettings,
|
|
effectiveAutoApproved,
|
|
effectiveAutoApprovedCount,
|
|
isOverridden,
|
|
disabled,
|
|
mcpProbingServerIds,
|
|
onUpdate,
|
|
}: {
|
|
approvalTools: ApprovalToolDefinition[];
|
|
toolingSettings: WorkspaceToolingSettings;
|
|
effectiveAutoApproved: Set<string>;
|
|
effectiveAutoApprovedCount: number;
|
|
isOverridden: boolean;
|
|
disabled: boolean;
|
|
mcpProbingServerIds?: string[];
|
|
onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void;
|
|
}){
|
|
const [open, setOpen] = useState(false);
|
|
const [search, setSearch] = useState('');
|
|
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set());
|
|
const ref = useClickOutside<HTMLDivElement>(() => { setOpen(false); setSearch(''); }, open);
|
|
|
|
const probingSet = useMemo(
|
|
() => new Set(mcpProbingServerIds ?? []),
|
|
[mcpProbingServerIds],
|
|
);
|
|
const isProbingAny = probingSet.size > 0;
|
|
|
|
const groups = useMemo(
|
|
() => groupApprovalToolsByProvider(approvalTools, toolingSettings),
|
|
[approvalTools, toolingSettings],
|
|
);
|
|
|
|
const totalItemCount = groups.reduce(
|
|
(sum, g) => sum + Math.max(g.tools.length, g.serverApprovalKey ? 1 : 0),
|
|
0,
|
|
);
|
|
const showSearch = totalItemCount > 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 || g.label.toLowerCase().includes(searchLower));
|
|
}, [groups, searchLower]);
|
|
|
|
function toggleTool(toolId: string) {
|
|
const next = new Set(effectiveAutoApproved);
|
|
if (next.has(toolId)) {
|
|
next.delete(toolId);
|
|
} else {
|
|
next.add(toolId);
|
|
}
|
|
onUpdate({ autoApprovedToolNames: [...next] });
|
|
}
|
|
|
|
function toggleGroup(group: ApprovalToolGroup) {
|
|
const next = new Set(effectiveAutoApproved);
|
|
|
|
if (group.serverApprovalKey) {
|
|
// MCP servers use server-level approval key
|
|
if (next.has(group.serverApprovalKey)) {
|
|
next.delete(group.serverApprovalKey);
|
|
} else {
|
|
next.add(group.serverApprovalKey);
|
|
}
|
|
// Also remove individual tool entries when toggling server-level
|
|
for (const tool of group.tools) {
|
|
next.delete(tool.id);
|
|
}
|
|
} else {
|
|
// Non-MCP groups: toggle individual tools
|
|
const allApproved = group.tools.every((t) => next.has(t.id));
|
|
for (const tool of group.tools) {
|
|
if (allApproved) {
|
|
next.delete(tool.id);
|
|
} else {
|
|
next.add(tool.id);
|
|
}
|
|
}
|
|
}
|
|
|
|
onUpdate({ autoApprovedToolNames: [...next] });
|
|
}
|
|
|
|
function isGroupApproved(group: ApprovalToolGroup): 'all' | 'some' | 'none' {
|
|
if (group.serverApprovalKey && effectiveAutoApproved.has(group.serverApprovalKey)) {
|
|
return 'all';
|
|
}
|
|
if (group.tools.length === 0) return 'none';
|
|
const approvedCount = group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length;
|
|
if (approvedCount === group.tools.length) return 'all';
|
|
if (approvedCount > 0) return 'some';
|
|
return 'none';
|
|
}
|
|
|
|
function isGroupProbing(group: ApprovalToolGroup): boolean {
|
|
if (group.kind !== 'mcp') return false;
|
|
const serverId = group.id.replace(/^mcp:/, '');
|
|
return probingSet.has(serverId);
|
|
}
|
|
|
|
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}>
|
|
<button
|
|
aria-expanded={open}
|
|
aria-haspopup="listbox"
|
|
className={`inline-flex items-center gap-1 rounded border px-1.5 py-0.5 text-pill font-medium transition-all duration-200 ${
|
|
open
|
|
? 'border-[var(--color-border-glow)] bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
|
|
: isOverridden
|
|
? 'border-[var(--color-status-warning)]/30 bg-[var(--color-status-warning)]/5 text-[var(--color-status-warning)] hover:border-[var(--color-status-warning)]/50'
|
|
: 'border-[var(--color-border-subtle)] bg-[var(--color-surface-2)]/40 text-[var(--color-text-secondary)] hover:border-[var(--color-border)] hover:text-[var(--color-text-primary)]'
|
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
disabled={disabled}
|
|
onClick={() => setOpen(!open)}
|
|
type="button"
|
|
>
|
|
{isProbingAny ? (
|
|
<Loader2 className="size-2.5 animate-spin" aria-label="Probing MCP servers" />
|
|
) : (
|
|
<ShieldCheck className="size-2.5" />
|
|
)}
|
|
<span>
|
|
{effectiveAutoApprovedCount}/{totalItemCount} auto-approved
|
|
{isProbingAny && <span className="text-[var(--color-text-muted)]"> · probing…</span>}
|
|
</span>
|
|
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
|
|
</button>
|
|
|
|
{open && !disabled && (
|
|
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
|
{/* Header: session override / pattern defaults */}
|
|
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
|
<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-[var(--color-status-warning)]/15 text-[var(--color-status-warning)]'
|
|
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)]'
|
|
}`}>
|
|
{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-[var(--color-text-muted)] transition-all duration-200 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
|
|
onClick={() => onUpdate({})}
|
|
type="button"
|
|
>
|
|
<RotateCcw className="size-2.5" />
|
|
Reset
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Search */}
|
|
{showSearch && (
|
|
<div className="border-t border-[var(--color-border-subtle)] px-3 py-1.5">
|
|
<div className="flex items-center gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface-2)]/30 px-2 py-1">
|
|
<Search className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
|
<input
|
|
autoFocus
|
|
className="w-full bg-transparent text-[12px] text-[var(--color-text-primary)] placeholder-[var(--color-text-muted)] outline-none"
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
placeholder="Filter tools…"
|
|
type="text"
|
|
value={search}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Tool groups */}
|
|
<div className="py-1">
|
|
{filteredGroups.map((group, groupIdx) => {
|
|
const isBuiltin = group.kind === 'builtin';
|
|
const isCollapsible = !isBuiltin;
|
|
const expanded = isBuiltin || isGroupExpanded(group.id);
|
|
const probing = isGroupProbing(group);
|
|
const groupState = isGroupApproved(group);
|
|
const allApproved = groupState === 'all';
|
|
const someApproved = groupState === 'some';
|
|
const approvedLabel = group.serverApprovalKey && allApproved
|
|
? 'all'
|
|
: `${group.tools.filter((t) => effectiveAutoApproved.has(t.id)).length}/${group.tools.length}`;
|
|
|
|
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-[var(--color-text-muted)]`}>
|
|
{group.label}
|
|
</div>
|
|
) : (
|
|
<div
|
|
className={`flex w-full cursor-pointer items-center gap-1.5 px-2.5 py-1.5 text-left transition-all duration-200 hover:bg-[var(--color-surface-3)]/60 ${groupIdx > 0 ? 'mt-0.5' : ''}`}
|
|
onClick={() => toggleExpanded(group.id)}
|
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleExpanded(group.id); } }}
|
|
role="button"
|
|
tabIndex={0}
|
|
>
|
|
{probing ? (
|
|
<Loader2 className="size-3 shrink-0 animate-spin text-[var(--color-text-accent)]" aria-label="Probing server" />
|
|
) : group.tools.length > 0 ? (
|
|
<ChevronRight className={`size-3 shrink-0 text-[var(--color-text-muted)] transition ${expanded ? 'rotate-90' : ''}`} />
|
|
) : (
|
|
<Server className="size-3 shrink-0 text-[var(--color-text-muted)]" />
|
|
)}
|
|
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--color-text-primary)]">{group.label}</span>
|
|
{probing ? (
|
|
<span className="shrink-0 rounded-full bg-[var(--color-accent-muted)] px-1.5 py-px text-[9px] font-medium text-[var(--color-text-accent)]">
|
|
probing…
|
|
</span>
|
|
) : (
|
|
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)]/80 px-1.5 py-px text-[9px] font-medium tabular-nums text-[var(--color-text-muted)]">
|
|
{approvedLabel}
|
|
</span>
|
|
)}
|
|
{!probing && (
|
|
<GroupToggle
|
|
allApproved={allApproved}
|
|
someApproved={someApproved}
|
|
onToggle={(e) => { e.stopPropagation(); toggleGroup(group); }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 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-[var(--color-text-muted)]">
|
|
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-[var(--color-accent)]' : someApproved ? 'bg-[var(--color-surface-3)]' : 'bg-[var(--color-surface-3)]'
|
|
}`}
|
|
onClick={onToggle}
|
|
type="button"
|
|
>
|
|
{someApproved ? (
|
|
<Minus className="absolute left-1/2 size-2 -translate-x-1/2 text-[var(--color-text-primary)]" strokeWidth={3} />
|
|
) : (
|
|
<span
|
|
className={`inline-block size-[12px] rounded-full bg-white shadow transition-transform ${
|
|
allApproved ? 'translate-x-[13px]' : 'translate-x-[2px]'
|
|
}`}
|
|
/>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
/* ── InlineTerminalPill ────────────────────────────────────── */
|
|
|
|
export function InlineTerminalPill({
|
|
disabled,
|
|
isRunning,
|
|
isOpen,
|
|
onToggle,
|
|
}: {
|
|
disabled: boolean;
|
|
isRunning: boolean;
|
|
isOpen: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
return (
|
|
<button
|
|
aria-pressed={isOpen}
|
|
className={`inline-flex items-center gap-1 rounded-lg px-2 py-1 text-[11px] font-medium transition-all duration-200 ${
|
|
isOpen
|
|
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)] hover:bg-[var(--color-accent)]/20'
|
|
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]'
|
|
} disabled:cursor-not-allowed disabled:opacity-50`}
|
|
disabled={disabled}
|
|
onClick={onToggle}
|
|
type="button"
|
|
>
|
|
{isRunning && <span className="size-1.5 shrink-0 rounded-full bg-[var(--color-status-success)]" />}
|
|
<TerminalSquare className="size-3" />
|
|
<span>Terminal</span>
|
|
</button>
|
|
);
|
|
}
|