Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
175 changes: 175 additions & 0 deletions src/components/extensions/agent-filter.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<AgentOption[]>(
() => [
{ name: null, label: allAgentsLabel },
...enabledAgents.map((agent) => ({
name: agent.name,
label: agentDisplayName(agent.name),
})),
],
[allAgentsLabel, enabledAgents],
);
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement | null>(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 (
<div ref={rootRef} className="relative shrink-0">
<button
type="button"
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => {
if (
e.key === "ArrowDown" ||
e.key === "ArrowUp" ||
e.key === "Enter" ||
e.key === " "
) {
e.preventDefault();
setOpen(true);
}
}}
className={clsx(
"flex min-w-36 items-center gap-2 border bg-card px-3 text-xs text-foreground transition-colors focus:outline-none",
web ? "h-[26px] rounded-[6px]" : "rounded-lg py-1.5",
agentFilter && AGENT_FILTER_BORDERS[agentFilter]
? AGENT_FILTER_BORDERS[agentFilter]
: "border-border focus:border-ring",
)}
>
{selectedAgent && <AgentMascot name={selectedAgent} size={16} />}
<span className="truncate">{selectedLabel}</span>
<ChevronDown
size={14}
className="ml-auto shrink-0 text-muted-foreground"
/>
</button>
{open && (
<div
role="listbox"
aria-label={ariaLabel}
className="absolute right-0 top-full z-50 mt-1 min-w-52 overflow-hidden rounded-xl border border-border/60 bg-background p-1 shadow-sm"
>
{options.map((option, index) => {
const selected = option.name === agentFilter;
const active = index === activeIndex;
return (
<button
key={option.name ?? "all"}
type="button"
role="option"
aria-selected={selected}
data-active={active ? "true" : undefined}
onMouseEnter={() => setActiveIndex(index)}
onClick={() => {
onChange(option.name);
setOpen(false);
}}
className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm text-foreground hover:bg-accent/60 data-[active=true]:bg-accent"
>
{option.name && <AgentMascot name={option.name} size={16} />}
<span className="flex-1 text-left">{option.label}</span>
{selected && (
<Check size={12} className="shrink-0 text-foreground" />
)}
</button>
);
})}
</div>
)}
</div>
);
}
42 changes: 9 additions & 33 deletions src/components/extensions/extension-filters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, string> = {
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");
Expand Down Expand Up @@ -141,26 +130,13 @@ export function ExtensionFilters() {
)}
<div className="flex-1" />
{enabledAgents.length > 0 && (
<select
value={agentFilter ?? ""}
onChange={(e) => setAgentFilter(e.target.value || null)}
aria-label={t("filters.filterByAgent")}
style={webSelectStyle}
className={clsx(
"shrink-0 border px-3 text-xs capitalize focus:outline-none transition-colors",
web ? "rounded-[6px] h-[26px]" : "rounded-lg py-1.5",
agentFilter && AGENT_FILTER_COLORS[agentFilter]
? `${AGENT_FILTER_COLORS[agentFilter]}${web ? " font-medium" : ""}`
: "border-border bg-card text-foreground focus:border-ring",
)}
>
<option value="">{t("filters.allAgents")}</option>
{enabledAgents.map((agent) => (
<option key={agent.name} value={agent.name}>
{agentDisplayName(agent.name)}
</option>
))}
</select>
<AgentFilter
agentFilter={agentFilter}
enabledAgents={enabledAgents}
onChange={setAgentFilter}
ariaLabel={t("filters.filterByAgent")}
allAgentsLabel={t("filters.allAgents")}
/>
)}
{scopedPacks.length > 0 && (
<select
Expand Down
Loading