Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
310 lines
9.8 KiB
TypeScript
310 lines
9.8 KiB
TypeScript
/**
|
|
* AgentSelector - Dropdown for switching between AI agents
|
|
*
|
|
* Dark, grouped agent menu with local SVG branding for built-in,
|
|
* discovered, and external agents.
|
|
*/
|
|
|
|
import { ChevronDown, RefreshCw, Plus, Settings } from 'lucide-react';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { cn } from '../../lib/utils';
|
|
import { useI18n } from '../../application/i18n/I18nProvider';
|
|
import {
|
|
getExternalAgentSdkBackend,
|
|
isSettingsManagedDiscoveredAgent,
|
|
matchesManagedAgentConfig,
|
|
} from '../../infrastructure/ai/managedAgents';
|
|
import type { AgentInfo, ExternalAgentConfig, DiscoveredAgent } from '../../infrastructure/ai/types';
|
|
import AgentIconBadge from './AgentIconBadge';
|
|
import {
|
|
Dropdown,
|
|
DropdownContent,
|
|
DropdownTrigger,
|
|
} from '../ui/dropdown';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
|
|
|
interface AgentSelectorProps {
|
|
currentAgentId: string;
|
|
externalAgents: ExternalAgentConfig[];
|
|
discoveredAgents?: DiscoveredAgent[];
|
|
isDiscovering?: boolean;
|
|
onSelectAgent: (agentId: string) => void;
|
|
onEnableDiscoveredAgent?: (agent: DiscoveredAgent) => void;
|
|
onRediscover?: () => void;
|
|
onManageAgents?: () => void;
|
|
parked?: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
const BUILTIN_AGENTS: AgentInfo[] = [
|
|
{
|
|
id: 'catty',
|
|
name: 'Catty Agent',
|
|
type: 'builtin',
|
|
description: 'Built-in terminal assistant',
|
|
available: true,
|
|
},
|
|
];
|
|
|
|
const SectionLabel: React.FC<{ children: React.ReactNode; action?: React.ReactNode }> = ({ children, action }) => (
|
|
<div className="flex items-center justify-between px-3 pb-1.5 pt-1.5">
|
|
<span className="text-[10px] font-medium tracking-wide text-muted-foreground/52">
|
|
{children}
|
|
</span>
|
|
{action}
|
|
</div>
|
|
);
|
|
|
|
const AgentMenuRow: React.FC<{
|
|
agent: AgentInfo;
|
|
isActive?: boolean;
|
|
subtitle?: string;
|
|
onClick: () => void;
|
|
}> = ({ agent, isActive, subtitle, onClick }) => {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className={cn(
|
|
'flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/86 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30',
|
|
isActive && 'bg-muted',
|
|
)}
|
|
>
|
|
<AgentIconBadge agent={agent} size="xs" variant="plain" className="opacity-78" />
|
|
<div className="min-w-0 flex-1">
|
|
<span className="block truncate">{agent.name}</span>
|
|
{subtitle && (
|
|
<span className="block truncate text-[10px] text-muted-foreground/40">{subtitle}</span>
|
|
)}
|
|
</div>
|
|
</button>
|
|
);
|
|
};
|
|
|
|
const DiscoveredAgentRow: React.FC<{
|
|
agent: DiscoveredAgent;
|
|
onEnable: () => void;
|
|
}> = ({ agent, onEnable }) => {
|
|
const { t } = useI18n();
|
|
const agentLike: AgentInfo = {
|
|
id: `discovered_${agent.command}`,
|
|
name: agent.name,
|
|
type: 'external',
|
|
icon: agent.icon,
|
|
command: agent.command,
|
|
available: true,
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-9 w-full items-center gap-2.5 rounded px-3 text-xs">
|
|
<AgentIconBadge agent={agentLike} size="xs" variant="plain" className="opacity-78" />
|
|
<div className="min-w-0 flex-1">
|
|
<span className="block truncate text-foreground/86">{agent.name}</span>
|
|
<span className="block truncate text-[10px] text-muted-foreground/40">
|
|
{agent.version || agent.path}
|
|
</span>
|
|
</div>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={onEnable}
|
|
className="shrink-0 rounded-md px-2 py-0.5 text-[11px] font-medium text-primary/80 hover:bg-primary/10 hover:text-primary transition-colors cursor-pointer"
|
|
>
|
|
<Plus size={12} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.enableAgent', { name: agent.name })}</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
const AgentSelector: React.FC<AgentSelectorProps> = ({
|
|
currentAgentId,
|
|
externalAgents,
|
|
discoveredAgents = [],
|
|
isDiscovering = false,
|
|
onSelectAgent,
|
|
onEnableDiscoveredAgent,
|
|
onRediscover,
|
|
onManageAgents,
|
|
parked = false,
|
|
disabled = false,
|
|
}) => {
|
|
const { t } = useI18n();
|
|
const [open, setOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (parked || disabled) setOpen(false);
|
|
}, [disabled, parked]);
|
|
|
|
const enabledExternalAgents = useMemo(
|
|
() =>
|
|
externalAgents
|
|
.filter((agent) => agent.enabled && Boolean(getExternalAgentSdkBackend(agent)))
|
|
.map(
|
|
(agent): AgentInfo => ({
|
|
id: agent.id,
|
|
name: agent.name,
|
|
type: 'external',
|
|
icon: agent.icon,
|
|
command: agent.command,
|
|
args: agent.args,
|
|
available: true,
|
|
}),
|
|
),
|
|
[externalAgents],
|
|
);
|
|
|
|
// Discovered agents not yet added to external agents
|
|
const unconfiguredDiscovered = useMemo(
|
|
() =>
|
|
discoveredAgents.filter(
|
|
(da) => {
|
|
if (isSettingsManagedDiscoveredAgent(da)) {
|
|
return !externalAgents.some((ea) => matchesManagedAgentConfig(ea, da.command));
|
|
}
|
|
return !externalAgents.some((ea) => ea.command === da.command || ea.command === da.path);
|
|
},
|
|
),
|
|
[discoveredAgents, externalAgents],
|
|
);
|
|
|
|
const allAgents = useMemo(
|
|
() => [...BUILTIN_AGENTS, ...enabledExternalAgents],
|
|
[enabledExternalAgents],
|
|
);
|
|
|
|
const currentAgent = useMemo(
|
|
() => allAgents.find((agent) => agent.id === currentAgentId) ?? BUILTIN_AGENTS[0],
|
|
[allAgents, currentAgentId],
|
|
);
|
|
|
|
const handleSelect = useCallback(
|
|
(agentId: string) => {
|
|
onSelectAgent(agentId);
|
|
setOpen(false);
|
|
},
|
|
[onSelectAgent],
|
|
);
|
|
|
|
const handleEnableDiscovered = useCallback(
|
|
(agent: DiscoveredAgent) => {
|
|
onEnableDiscoveredAgent?.(agent);
|
|
// After enabling, auto-select it
|
|
const agentId = `discovered_${agent.command}`;
|
|
onSelectAgent(agentId);
|
|
setOpen(false);
|
|
},
|
|
[onEnableDiscoveredAgent, onSelectAgent],
|
|
);
|
|
|
|
const handleManageAgents = useCallback(() => {
|
|
setOpen(false);
|
|
onManageAgents?.();
|
|
}, [onManageAgents]);
|
|
|
|
return (
|
|
<Dropdown open={open} onOpenChange={setOpen}>
|
|
<DropdownTrigger asChild>
|
|
<button
|
|
type="button"
|
|
disabled={disabled}
|
|
className="group flex h-6 min-w-0 max-w-[170px] items-center gap-1.5 rounded-md px-1.5 text-left transition-colors hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/28 disabled:pointer-events-none disabled:opacity-50"
|
|
>
|
|
<AgentIconBadge
|
|
agent={currentAgent}
|
|
size="xs"
|
|
variant="plain"
|
|
className="h-3 w-3 opacity-78"
|
|
/>
|
|
<span className="min-w-0 flex-1 truncate text-[11px] font-medium text-foreground/90">
|
|
{currentAgent.name}
|
|
</span>
|
|
<ChevronDown
|
|
size={10}
|
|
className={cn(
|
|
'shrink-0 text-muted-foreground/60 transition-transform',
|
|
open && 'rotate-180',
|
|
)}
|
|
/>
|
|
</button>
|
|
</DropdownTrigger>
|
|
|
|
<DropdownContent
|
|
align="start"
|
|
sideOffset={6}
|
|
className="w-[256px] overflow-hidden rounded-md border border-border/50 bg-popover p-0 text-foreground shadow-lg supports-[backdrop-filter]:backdrop-blur-sm"
|
|
>
|
|
{BUILTIN_AGENTS.map((agent) => (
|
|
<AgentMenuRow
|
|
key={agent.id}
|
|
agent={agent}
|
|
isActive={currentAgentId === agent.id}
|
|
onClick={() => handleSelect(agent.id)}
|
|
/>
|
|
))}
|
|
|
|
{enabledExternalAgents.length > 0 && (
|
|
<>
|
|
<div className="mx-0 my-1 border-t border-border/50" />
|
|
<SectionLabel>{t('ai.chat.agents')}</SectionLabel>
|
|
{enabledExternalAgents.map((agent) => (
|
|
<AgentMenuRow
|
|
key={agent.id}
|
|
agent={agent}
|
|
isActive={currentAgentId === agent.id}
|
|
subtitle={agent.command}
|
|
onClick={() => handleSelect(agent.id)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
{unconfiguredDiscovered.length > 0 && (
|
|
<>
|
|
<div className="mx-0 my-1 border-t border-border/50" />
|
|
<SectionLabel
|
|
action={
|
|
onRediscover && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
onClick={onRediscover}
|
|
disabled={isDiscovering}
|
|
className="text-[10px] text-muted-foreground/40 hover:text-muted-foreground/70 transition-colors cursor-pointer disabled:opacity-50"
|
|
>
|
|
<RefreshCw size={10} className={cn(isDiscovering && 'animate-spin')} />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('ai.chat.rescan')}</TooltipContent>
|
|
</Tooltip>
|
|
)
|
|
}
|
|
>
|
|
{t('ai.chat.detectedOnMachine')}
|
|
</SectionLabel>
|
|
{unconfiguredDiscovered.map((agent) => (
|
|
<DiscoveredAgentRow
|
|
key={agent.command}
|
|
agent={agent}
|
|
onEnable={() => handleEnableDiscovered(agent)}
|
|
/>
|
|
))}
|
|
</>
|
|
)}
|
|
|
|
<div className="mx-0 my-1 border-t border-border/50" />
|
|
<button
|
|
onClick={handleManageAgents}
|
|
className="flex h-9 w-full items-center gap-2.5 px-3 text-left text-xs text-foreground/82 transition-colors cursor-pointer hover:bg-muted focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary/30"
|
|
>
|
|
<Settings size={14} className="opacity-72 shrink-0" />
|
|
<span className="min-w-0 flex-1 truncate">{t('ai.agentSettings')}</span>
|
|
</button>
|
|
</DropdownContent>
|
|
</Dropdown>
|
|
);
|
|
};
|
|
|
|
export default React.memo(AgentSelector);
|