diff --git a/src/components/extensions/agent-filter.tsx b/src/components/extensions/agent-filter.tsx new file mode 100644 index 00000000..3f20ac1c --- /dev/null +++ b/src/components/extensions/agent-filter.tsx @@ -0,0 +1,175 @@ +import { clsx } from "clsx"; +import { Check, ChevronDown } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; +import { agentDisplayName } from "@/lib/types"; +import { isWeb as web } from "@/lib/web-select"; + +/** Per-agent border colors for the active filter trigger state. */ +const AGENT_FILTER_BORDERS: Record = { + claude: "border-agent-claude", + codex: "border-agent-codex", + gemini: "border-agent-gemini", + cursor: "border-agent-cursor", + antigravity: "border-agent-antigravity", + copilot: "border-agent-copilot", + windsurf: "border-agent-windsurf", + opencode: "border-agent-opencode", +}; + +interface AgentOption { + name: string | null; + label: string; +} + +interface AgentFilterProps { + agentFilter: string | null; + enabledAgents: { name: string }[]; + onChange: (agent: string | null) => void; + ariaLabel: string; + allAgentsLabel: string; +} + +export function AgentFilter({ + agentFilter, + enabledAgents, + onChange, + ariaLabel, + allAgentsLabel, +}: AgentFilterProps) { + const options = useMemo( + () => [ + { name: null, label: allAgentsLabel }, + ...enabledAgents.map((agent) => ({ + name: agent.name, + label: agentDisplayName(agent.name), + })), + ], + [allAgentsLabel, enabledAgents], + ); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const selectedIndex = options.findIndex((o) => o.name === agentFilter); + const [activeIndex, setActiveIndex] = useState( + selectedIndex >= 0 ? selectedIndex : 0, + ); + + useEffect(() => { + if (!open) { + setActiveIndex(selectedIndex >= 0 ? selectedIndex : 0); + } + }, [open, selectedIndex]); + + useEffect(() => { + if (!open) return; + const onMouseDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) { + setOpen(false); + } + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + e.preventDefault(); + setOpen(false); + return; + } + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, options.length - 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const option = options[activeIndex]; + if (!option) return; + onChange(option.name); + setOpen(false); + } + }; + document.addEventListener("mousedown", onMouseDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onMouseDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [activeIndex, onChange, open, options]); + + const selectedLabel = + options.find((o) => o.name === agentFilter)?.label ?? allAgentsLabel; + const selectedAgent = agentFilter ?? null; + + return ( +
+ + {open && ( +
+ {options.map((option, index) => { + const selected = option.name === agentFilter; + const active = index === activeIndex; + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index 540d0fb8..be017c3c 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -2,8 +2,9 @@ import { clsx } from "clsx"; import { Search, X } from "lucide-react"; import { useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; -import { agentDisplayName, type ExtensionKind, sortAgents } from "@/lib/types"; +import { type ExtensionKind, sortAgents } from "@/lib/types"; import { isWeb as web, webSelectStyle } from "@/lib/web-select"; +import { AgentFilter } from "@/components/extensions/agent-filter"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; import { useScopeStore } from "@/stores/scope-store"; @@ -31,18 +32,6 @@ const kinds: (ExtensionKind | null)[] = [ "hook", "cli", ]; -/** Per-agent background + text colors for the active filter state. */ -const AGENT_FILTER_COLORS: Record = { - claude: "bg-agent-claude/15 text-agent-claude border-agent-claude/30", - codex: "bg-agent-codex/15 text-agent-codex border-agent-codex/30", - gemini: "bg-agent-gemini/15 text-agent-gemini border-agent-gemini/30", - cursor: "bg-agent-cursor/15 text-agent-cursor border-agent-cursor/30", - antigravity: - "bg-agent-antigravity/15 text-agent-antigravity border-agent-antigravity/30", - copilot: "bg-agent-copilot/15 text-agent-copilot border-agent-copilot/30", - windsurf: "bg-agent-windsurf/15 text-agent-windsurf border-agent-windsurf/30", - opencode: "bg-agent-opencode/15 text-agent-opencode border-agent-opencode/30", -}; export function ExtensionFilters() { const { t } = useTranslation("extensions"); @@ -141,26 +130,13 @@ export function ExtensionFilters() { )}
{enabledAgents.length > 0 && ( - + )} {scopedPacks.length > 0 && (