Skip to content
Closed
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
101 changes: 93 additions & 8 deletions src/features/ai/components/selectors/agent-selector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ import {
SlidersHorizontal as Settings2,
SpinnerGap,
} from "@phosphor-icons/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type WheelEvent as ReactWheelEvent,
} from "react";
import { ProviderIcon } from "@/features/ai/components/icons/provider-icons";
import { AcpStreamHandler } from "@/features/ai/services/acp-stream-handler";
import { useAIChatStore } from "@/features/ai/store/store";
Expand All @@ -23,9 +30,52 @@ const ATHAS_AGENT_OPTION = {
id: "custom",
name: "Athas Agent",
description: "Use Athas chat settings and provider configuration",
icon: null,
isAcp: false,
};

function AgentIcon({
agentId,
icon,
size,
className,
}: {
agentId: string;
icon?: string | null;
size: number;
className?: string;
}) {
const [didFail, setDidFail] = useState(false);

if (icon && !didFail) {
return (
<img
aria-hidden="true"
src={icon}
alt=""
width={size}
height={size}
referrerPolicy="no-referrer"
onError={() => setDidFail(true)}
className={cn("shrink-0 object-contain", className)}
style={{
filter: `brightness(0) saturate(100%) invert(82%) sepia(82%) saturate(1180%) hue-rotate(${agentIconHue(agentId)}deg) brightness(101%) contrast(96%)`,
}}
/>
);
}

return <ProviderIcon providerId={agentId} size={size} className={className} />;
}

function agentIconHue(agentId: string) {
let hash = 0;
for (let index = 0; index < agentId.length; index++) {
hash = (hash * 31 + agentId.charCodeAt(index)) >>> 0;
}
return hash % 360;
}

interface AgentSelectorProps {
variant?: "header" | "input";
onOpenSettings?: () => void;
Expand Down Expand Up @@ -60,6 +110,8 @@ export function AgentSelector({

const triggerRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const listRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLDivElement | null>>([]);
const previousOpenSignalRef = useRef(openSignal);

const currentAgentId = selectedAgentId ?? getCurrentAgentId();
Expand Down Expand Up @@ -93,6 +145,7 @@ export function AgentSelector({
id: string;
name: string;
description: string;
icon?: string | null;
isInstalled?: boolean;
isCurrent?: boolean;
canInstall?: boolean;
Expand Down Expand Up @@ -120,6 +173,7 @@ export function AgentSelector({
id: agent.id,
name: agent.name,
description: agentConfig?.description ?? agent.description ?? "ACP-compatible coding agent",
icon: agentConfig?.icon ?? agent.icon,
isInstalled,
isCurrent: agent.id === currentAgentId,
canInstall: agent.id === "custom" ? false : (agentConfig?.canInstall ?? true),
Expand Down Expand Up @@ -156,6 +210,20 @@ export function AgentSelector({
}
}, [isOpen]);

useEffect(() => {
if (!isOpen) return;
itemRefs.current[selectedIndex]?.scrollIntoView({ block: "nearest" });
}, [isOpen, selectedIndex]);

const handleAgentListWheel = useCallback((event: ReactWheelEvent<HTMLDivElement>) => {
const list = event.currentTarget;
if (list.scrollHeight <= list.clientHeight || event.deltaY === 0) return;

event.preventDefault();
event.stopPropagation();
list.scrollTop += event.deltaY;
}, []);

const handleAgentChange = useCallback(
async (agentId: AgentType) => {
if (onSelectAgent) {
Expand Down Expand Up @@ -284,7 +352,12 @@ export function AgentSelector({
compact
className="ui-font flex h-8 max-w-[min(220px,100%)] items-center gap-1.5 rounded-full border border-border bg-secondary-bg/80 px-3 text-xs transition-colors hover:bg-hover"
>
<ProviderIcon providerId={currentAgentId} size={11} className="text-text-lighter" />
<AgentIcon
agentId={currentAgentId}
icon={currentAgent.icon}
size={11}
className="text-text-lighter"
/>
<span className="max-w-[140px] truncate text-text">{currentAgent?.name || "Agent"}</span>
<ChevronDown
className={cn("text-text-lighter transition-transform", isOpen && "rotate-180")}
Expand All @@ -299,8 +372,8 @@ export function AgentSelector({
anchorAlign="end"
onClose={() => setIsOpen(false)}
portalContainer={portalContainer}
className="flex w-[min(280px,calc(100vw-16px))] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-xl p-0"
style={{ maxHeight: "240px" }}
className="flex w-[min(340px,calc(100vw-16px))] max-w-[calc(100vw-16px)] flex-col overflow-hidden rounded-xl p-0"
style={{ maxHeight: "min(560px, calc(100vh - 24px))" }}
>
<div className="bg-secondary-bg px-1.5 py-1.5" onKeyDown={handleKeyDown}>
<Input
Expand All @@ -317,7 +390,11 @@ export function AgentSelector({
/>
</div>

<div className="min-h-0 flex-1 overflow-y-auto p-1 [overscroll-behavior:contain]">
<div
ref={listRef}
className="custom-scrollbar-thin min-h-0 flex-1 overflow-y-auto overscroll-contain p-1 pr-1.5 pb-2"
onWheel={handleAgentListWheel}
>
{filteredItems.length === 0 ? (
<div className="p-4 text-center text-text-lighter text-xs">No results found</div>
) : (
Expand All @@ -329,6 +406,9 @@ export function AgentSelector({
return (
<div
key={item.id}
ref={(element) => {
itemRefs.current[itemIndex] = element;
}}
role="button"
tabIndex={-1}
onMouseEnter={() => setSelectedIndex(itemIndex)}
Expand All @@ -342,16 +422,21 @@ export function AgentSelector({
}
}}
className={cn(
"group flex min-h-7 cursor-pointer items-center gap-2 rounded-md px-2 py-1 text-xs transition-colors",
"group mb-1 flex min-h-10 cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-xs transition-colors last:mb-2",
isSelected ? "bg-hover/90" : "bg-transparent",
item.isCurrent && "bg-selected/90 ring-1 ring-accent/10",
!item.isInstalled && item.id !== "custom" && "text-text-lighter",
)}
>
<div className="flex min-w-0 flex-1 items-center gap-2">
<ProviderIcon providerId={item.id} size={12} className="text-text-lighter" />
<AgentIcon
agentId={item.id}
icon={item.icon}
size={12}
className="text-text-lighter"
/>
<div className="min-w-0 flex-1">
<div className="truncate text-left text-text text-xs leading-4">
<div className="truncate text-left font-medium text-sm text-text leading-4">
{item.name}
</div>
{!item.isInstalled && item.id !== "custom" ? (
Expand Down
Loading