mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 22:17:13 +02:00
refactor: decompose large frontend components into focused modules
Split six monolithic component files into smaller, single-responsibility modules organized by feature domain: - ChatPane (1054→~250 lines): extract InlinePills, ApprovalBanner, ThinkingDots into chat/ directory - SettingsPanel (1027→~280 lines): extract McpServerEditor, LspProfileEditor, ToolingEditorShell into settings/ directory; mutation helpers into lib/settingsHelpers.ts - PatternEditor: use shared ToggleSwitch from ui/ - App.tsx: extract useTheme and useSidecarCapabilities into hooks/ - Sidebar: extracted accessibility improvements inline New shared primitives: - hooks/useClickOutside: replaces 5 duplicated click-outside listeners - components/ui/: ToggleSwitch, PopoverToggleRow, FormField, TextInput, TextareaInput, SelectInput, InfoCallout Accessibility improvements: - NewSessionModal: role=dialog, aria-modal, Escape-to-close - Pill dropdowns: aria-expanded, aria-haspopup, role=listbox/option - Sidebar context menu: role=menu/menuitem, Escape-to-close - SessionItem: Space key activation alongside Enter - ToolbarButton: aria-pressed for toggle state - ApprovalBanner: role=alert - QueuedApprovalsList: aria-expanded on toggle - ThinkingDots: aria-label No behavioral changes. All 137 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,154 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
|
||||
|
||||
/* ── ApprovalBanner ────────────────────────────────────────── */
|
||||
|
||||
export function ApprovalBanner({
|
||||
approval,
|
||||
onResolve,
|
||||
isResolving,
|
||||
position,
|
||||
total,
|
||||
}: {
|
||||
approval: PendingApprovalRecord;
|
||||
onResolve: (decision: ApprovalDecision) => void;
|
||||
isResolving: boolean;
|
||||
position?: number;
|
||||
total?: number;
|
||||
}) {
|
||||
const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval';
|
||||
const hasMessages = approval.messages && approval.messages.length > 0;
|
||||
const showPosition = position !== undefined && total !== undefined && total > 1;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ShieldAlert className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-amber-200">{approval.title}</span>
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
{kindLabel}
|
||||
</span>
|
||||
{showPosition && (
|
||||
<span className="rounded-full bg-zinc-800 px-2 py-0.5 text-[9px] font-semibold tabular-nums text-zinc-400">
|
||||
{position} of {total}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-[11px] text-zinc-400">
|
||||
{approval.agentName && <span>Agent: <span className="text-zinc-300">{approval.agentName}</span></span>}
|
||||
{approval.toolName && <span>Tool: <span className="text-zinc-300">{approval.toolName}</span></span>}
|
||||
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
|
||||
</div>
|
||||
|
||||
{approval.detail && (
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Final-response message preview */}
|
||||
{hasMessages && (
|
||||
<div className="mt-3 space-y-2 rounded-lg border border-zinc-800 bg-zinc-900/60 p-3">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
Pending messages — not yet published
|
||||
</p>
|
||||
{approval.messages!.map((message) => (
|
||||
<div className="mt-2" key={message.id}>
|
||||
<div className="mb-1 flex items-center gap-2 text-[11px] font-medium text-zinc-500">
|
||||
<Bot className="size-3" />
|
||||
<span>{message.authorName}</span>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-800/60 bg-zinc-900/40 px-3 py-2 text-[13px] leading-relaxed text-zinc-300">
|
||||
<MarkdownContent content={message.content} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isResolving}
|
||||
onClick={() => onResolve('approved')}
|
||||
type="button"
|
||||
>
|
||||
{isResolving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isResolving}
|
||||
onClick={() => onResolve('rejected')}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Reject
|
||||
</button>
|
||||
{showPosition && (
|
||||
<span className="ml-auto text-[10px] text-zinc-600">
|
||||
Next approval will appear after this one is resolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── QueuedApprovalsList ───────────────────────────────────── */
|
||||
|
||||
export function QueuedApprovalsList({ approvals }: { approvals: PendingApprovalRecord[] }) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-2 text-left"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck className="size-3 text-zinc-500" />
|
||||
<span className="text-[11px] font-medium text-zinc-400">
|
||||
{approvals.length} queued approval{approvals.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`ml-auto size-3 text-zinc-600 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="mt-2 space-y-1.5 border-t border-zinc-800/60 pt-2">
|
||||
{approvals.map((approval) => {
|
||||
const kindLabel = approval.kind === 'final-response' ? 'response' : 'tool';
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 rounded-md bg-zinc-800/40 px-2.5 py-1.5"
|
||||
key={approval.id}
|
||||
>
|
||||
<ShieldAlert className="size-3 shrink-0 text-zinc-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-400">{approval.title}</span>
|
||||
<span className="shrink-0 rounded-full bg-zinc-800 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{kindLabel}
|
||||
</span>
|
||||
{approval.toolName && (
|
||||
<span className="shrink-0 text-[10px] text-zinc-500">{approval.toolName}</span>
|
||||
)}
|
||||
{approval.agentName && (
|
||||
<span className="shrink-0 text-[10px] text-zinc-600">{approval.agentName}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, 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 { 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-amber-500/10 text-amber-400',
|
||||
standard: 'bg-zinc-700/50 text-zinc-500',
|
||||
fast: 'bg-emerald-500/10 text-emerald-400',
|
||||
};
|
||||
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.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
|
||||
open
|
||||
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
|
||||
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
{provider && <ProviderIcon provider={provider} className="size-3" />}
|
||||
<span className="max-w-[140px] truncate">{displayName}</span>
|
||||
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-72 w-64 overflow-y-auto rounded-lg border border-zinc-700 bg-zinc-900 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-zinc-500">
|
||||
{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 hover:bg-zinc-800 ${
|
||||
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
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-zinc-500">
|
||||
Other
|
||||
</div>
|
||||
{otherModels.map((model) => (
|
||||
<button
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-[13px] transition hover:bg-zinc-800 ${
|
||||
model.id === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
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.5 rounded-md border border-zinc-800/40 bg-zinc-800/20 px-2 py-1 text-[12px] text-zinc-600">
|
||||
<Sparkles className="size-3" />
|
||||
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.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
|
||||
open
|
||||
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
|
||||
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
<Sparkles className="size-3" />
|
||||
<span>{currentLabel}</span>
|
||||
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{open && !disabled && (
|
||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 w-36 overflow-hidden rounded-lg border border-zinc-700 bg-zinc-900 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 hover:bg-zinc-800 ${
|
||||
option.value === value ? 'bg-indigo-500/10 text-indigo-200' : 'text-zinc-300'
|
||||
}`}
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
|
||||
open
|
||||
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
|
||||
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
<Server className="size-3" />
|
||||
<span>{enabledCount}/{totalCount} tools</span>
|
||||
<ChevronDown className={`size-3 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-zinc-700 bg-zinc-900 py-1 shadow-2xl">
|
||||
{mcpServers.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
|
||||
MCP Servers
|
||||
</div>
|
||||
{mcpServers.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>
|
||||
)}
|
||||
{lspProfiles.length > 0 && (
|
||||
<div>
|
||||
<div className="px-3 pb-1 pt-2 text-[9px] font-semibold uppercase tracking-wider text-zinc-600">
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── InlineApprovalPill ────────────────────────────────────── */
|
||||
|
||||
const approvalKindOrder: ApprovalToolKind[] = ['builtin', 'mcp', 'lsp', 'mixed'];
|
||||
const approvalKindLabels: Record<ApprovalToolKind, string> = {
|
||||
builtin: 'Built-in',
|
||||
mcp: 'MCP Servers',
|
||||
lsp: 'Language Servers',
|
||||
mixed: 'Other',
|
||||
};
|
||||
|
||||
export function InlineApprovalPill({
|
||||
approvalTools,
|
||||
effectiveAutoApproved,
|
||||
isOverridden,
|
||||
disabled,
|
||||
onUpdate,
|
||||
}: {
|
||||
approvalTools: ApprovalToolDefinition[];
|
||||
effectiveAutoApproved: Set<string>;
|
||||
isOverridden: boolean;
|
||||
disabled: boolean;
|
||||
onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
|
||||
|
||||
function toggleTool(toolId: string) {
|
||||
const next = new Set(effectiveAutoApproved);
|
||||
if (next.has(toolId)) {
|
||||
next.delete(toolId);
|
||||
} else {
|
||||
next.add(toolId);
|
||||
}
|
||||
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;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}>
|
||||
<button
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
className={`inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-[12px] font-medium transition ${
|
||||
open
|
||||
? 'border-indigo-500/40 bg-indigo-500/10 text-indigo-300'
|
||||
: isOverridden
|
||||
? 'border-amber-500/30 bg-amber-500/5 text-amber-400 hover:border-amber-500/50'
|
||||
: 'border-zinc-700/60 bg-zinc-800/40 text-zinc-400 hover:border-zinc-600 hover:text-zinc-300'
|
||||
} disabled:cursor-not-allowed disabled:opacity-50`}
|
||||
disabled={disabled}
|
||||
onClick={() => setOpen(!open)}
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck className="size-3" />
|
||||
<span>{effectiveAutoApproved.size}/{approvalTools.length} auto-approved</span>
|
||||
<ChevronDown className={`size-3 transition ${open ? 'rotate-180' : ''}`} />
|
||||
</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>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function ThinkingDots() {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5" aria-label="Thinking">
|
||||
<span className="thinking-dot size-2 rounded-full bg-zinc-500" />
|
||||
<span className="thinking-dot size-2 rounded-full bg-zinc-500" />
|
||||
<span className="thinking-dot size-2 rounded-full bg-zinc-500" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user