feat: agent search functionality with filters and loading states (#2179)

* feat: implement agent search functionality with filters and loading states

* style: improve layout and styling of agent search input and description
This commit is contained in:
Siddhant Rai
2025-12-04 21:16:37 +05:30
committed by GitHub
parent 3352d42414
commit 9b9f95710a
11 changed files with 431 additions and 46 deletions

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import Search from '../assets/search.svg';
import Spinner from '../components/Spinner';
import {
setConversation,
@@ -10,19 +11,40 @@ import {
} from '../conversation/conversationSlice';
import {
selectSelectedAgent,
selectToken,
setSelectedAgent,
} from '../preferences/preferenceSlice';
import AgentCard from './AgentCard';
import { agentSectionsConfig } from './agents.config';
import { AgentSectionId, agentSectionsConfig } from './agents.config';
import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch';
import { useAgentsFetch } from './hooks/useAgentsFetch';
import { Agent } from './types';
const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
{ id: 'all', labelKey: 'agents.filters.all' },
{ id: 'template', labelKey: 'agents.filters.byDocsGPT' },
{ id: 'user', labelKey: 'agents.filters.byMe' },
{ id: 'shared', labelKey: 'agents.filters.shared' },
];
export default function AgentsList() {
const { t } = useTranslation();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const selectedAgent = useSelector(selectSelectedAgent);
const { isLoading } = useAgentsFetch();
const {
searchQuery,
setSearchQuery,
activeFilter,
setActiveFilter,
filteredAgentsBySection,
totalAgentsBySection,
hasAnyAgents,
hasFilteredResults,
isDataLoaded,
} = useAgentSearch();
useEffect(() => {
dispatch(setConversation([]));
dispatch(
@@ -31,57 +53,150 @@ export default function AgentsList() {
}),
);
if (selectedAgent) dispatch(setSelectedAgent(null));
}, [token]);
}, []);
const visibleSections = agentSectionsConfig.filter((config) => {
if (activeFilter !== 'all') {
return config.id === activeFilter;
}
const sectionId = config.id as AgentSectionId;
const hasAgentsInSection = totalAgentsBySection[sectionId] > 0;
const hasFilteredAgents = filteredAgentsBySection[sectionId].length > 0;
const sectionDataLoaded = isDataLoaded[sectionId];
if (!sectionDataLoaded) return true;
if (searchQuery) return hasFilteredAgents;
if (config.id === 'user') return true;
return hasAgentsInSection;
});
const showSearchEmptyState =
searchQuery &&
hasAnyAgents &&
!hasFilteredResults &&
activeFilter === 'all';
return (
<div className="p-4 md:p-12">
<h1 className="text-eerie-black mb-0 text-[32px] font-bold lg:text-[40px] dark:text-[#E0E0E0]">
{t('agents.title')}
</h1>
<p className="dark:text-gray-4000 mt-5 text-[15px] text-[#71717A]">
<p className="dark:text-gray-4000 mt-5 max-w-lg text-[15px] leading-6 text-[#71717A]">
{t('agents.description')}
</p>
{agentSectionsConfig.map((sectionConfig) => (
<AgentSection key={sectionConfig.id} config={sectionConfig} />
<div className="mt-6 flex flex-col gap-4 pb-4">
<div className="relative w-full max-w-md">
<img
src={Search}
alt=""
className="absolute top-1/2 left-4 h-5 w-5 -translate-y-1/2 opacity-40"
/>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t('agents.searchPlaceholder')}
className="h-[44px] w-full rounded-full border border-[#E5E5E5] bg-white py-2 pr-5 pl-11 text-sm shadow-[0_1px_4px_rgba(0,0,0,0.06)] transition-shadow outline-none placeholder:text-[#9CA3AF] focus:shadow-[0_2px_8px_rgba(0,0,0,0.1)] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:shadow-none dark:placeholder:text-[#6B7280]"
/>
</div>
<div className="flex flex-wrap gap-2">
{FILTER_TABS.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveFilter(tab.id)}
className={`rounded-full px-4 py-2 text-sm transition-colors ${
activeFilter === tab.id
? 'bg-[#E0E0E0] text-[#18181B] dark:bg-[#4A4A4A] dark:text-white'
: 'bg-transparent text-[#71717A] hover:bg-[#F5F5F5] dark:text-[#949494] dark:hover:bg-[#383838]/50'
}`}
>
{t(tab.labelKey)}
</button>
))}
</div>
</div>
{visibleSections.map((sectionConfig) => (
<AgentSection
key={sectionConfig.id}
config={sectionConfig}
filteredAgents={
filteredAgentsBySection[sectionConfig.id as AgentSectionId]
}
totalAgents={totalAgentsBySection[sectionConfig.id as AgentSectionId]}
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
/>
))}
{showSearchEmptyState && (
<div className="mt-12 flex flex-col items-center justify-center gap-2 text-[#71717A]">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
)}
</div>
);
}
interface AgentSectionProps {
config: (typeof agentSectionsConfig)[number];
filteredAgents: Agent[];
totalAgents: number;
searchQuery: string;
isFilteredView: boolean;
isLoading: boolean;
}
function AgentSection({
config,
}: {
config: (typeof agentSectionsConfig)[number];
}) {
filteredAgents,
totalAgents,
searchQuery,
isFilteredView,
isLoading,
}: AgentSectionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
const agents = useSelector(config.selectData);
const [loading, setLoading] = useState(true);
const allAgents = useSelector(config.selectData);
const updateAgents = (updatedAgents: Agent[]) => {
dispatch(config.updateAction(updatedAgents));
};
useEffect(() => {
const getAgents = async () => {
setLoading(true);
try {
const response = await config.fetchAgents(token);
if (!response.ok)
throw new Error(`Failed to fetch ${config.id} agents`);
const data = await response.json();
dispatch(config.updateAction(data));
} catch (error) {
console.error(`Error fetching ${config.id} agents:`, error);
dispatch(config.updateAction([]));
} finally {
setLoading(false);
}
};
getAgents();
}, [token, config, dispatch]);
const hasNoAgentsAtAll = !isLoading && totalAgents === 0;
const isSearchingWithNoResults =
!isLoading && searchQuery && filteredAgents.length === 0 && totalAgents > 0;
if (isFilteredView && isSearchingWithNoResults) {
return (
<div className="mt-12 flex flex-col items-center justify-center gap-2 text-[#71717A]">
<p className="text-lg">{t('agents.noSearchResults')}</p>
<p className="text-sm">{t('agents.tryDifferentSearch')}</p>
</div>
);
}
if (isFilteredView && hasNoAgentsAtAll) {
return (
<div className="mt-12 flex flex-col items-center justify-center gap-3 text-[#71717A]">
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
</button>
)}
</div>
);
}
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
@@ -103,24 +218,24 @@ function AgentSection({
)}
</div>
<div>
{loading ? (
<div className="flex h-72 w-full items-center justify-center">
{isLoading ? (
<div className="flex h-40 w-full items-center justify-center">
<Spinner />
</div>
) : agents && agents.length > 0 ? (
) : filteredAgents.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:flex sm:flex-wrap">
{agents.map((agent) => (
{filteredAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
agents={agents}
agents={allAgents || []}
updateAgents={updateAgents}
section={config.id}
/>
))}
</div>
) : (
<div className="flex h-72 w-full flex-col items-center justify-center gap-3 text-base text-[#18181B] dark:text-[#E0E0E0]">
) : hasNoAgentsAtAll ? (
<div className="flex h-40 w-full flex-col items-center justify-center gap-3 text-[#71717A]">
<p>{t(`agents.sections.${config.id}.emptyState`)}</p>
{config.showNewAgentButton && (
<button
@@ -131,7 +246,7 @@ function AgentSection({
</button>
)}
</div>
)}
) : null}
</div>
</div>
);

View File

@@ -1,16 +1,18 @@
import userService from '../api/services/userService';
import {
selectAgents,
selectTemplateAgents,
selectSharedAgents,
selectTemplateAgents,
setAgents,
setTemplateAgents,
setSharedAgents,
setTemplateAgents,
} from '../preferences/preferenceSlice';
export type AgentSectionId = 'template' | 'user' | 'shared';
export const agentSectionsConfig = [
{
id: 'template',
id: 'template' as const,
title: 'By DocsGPT',
description: 'Agents provided by DocsGPT',
showNewAgentButton: false,
@@ -20,7 +22,7 @@ export const agentSectionsConfig = [
updateAction: setTemplateAgents,
},
{
id: 'user',
id: 'user' as const,
title: 'By me',
description: 'Agents created or published by you',
showNewAgentButton: true,
@@ -30,7 +32,7 @@ export const agentSectionsConfig = [
updateAction: setAgents,
},
{
id: 'shared',
id: 'shared' as const,
title: 'Shared with me',
description: 'Agents imported by using a public link',
showNewAgentButton: false,

View File

@@ -0,0 +1,122 @@
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import {
selectAgents,
selectSharedAgents,
selectTemplateAgents,
} from '../../preferences/preferenceSlice';
import { AgentSectionId } from '../agents.config';
import { Agent } from '../types';
export type AgentFilterTab = 'all' | AgentSectionId;
export type AgentsBySection = Record<AgentSectionId, Agent[]>;
interface UseAgentSearchResult {
searchQuery: string;
setSearchQuery: (query: string) => void;
activeFilter: AgentFilterTab;
setActiveFilter: (filter: AgentFilterTab) => void;
filteredAgentsBySection: AgentsBySection;
totalAgentsBySection: Record<AgentSectionId, number>;
hasAnyAgents: boolean;
hasFilteredResults: boolean;
isDataLoaded: Record<AgentSectionId, boolean>;
}
const filterAgentsByQuery = (
agents: Agent[] | null,
query: string,
): Agent[] => {
if (!agents) return [];
if (!query.trim()) return agents;
const normalizedQuery = query.toLowerCase().trim();
return agents.filter(
(agent) =>
agent.name.toLowerCase().includes(normalizedQuery) ||
agent.description?.toLowerCase().includes(normalizedQuery),
);
};
export function useAgentSearch(): UseAgentSearchResult {
const [searchQuery, setSearchQuery] = useState('');
const [activeFilter, setActiveFilter] = useState<AgentFilterTab>('all');
const templateAgents = useSelector(selectTemplateAgents);
const userAgents = useSelector(selectAgents);
const sharedAgents = useSelector(selectSharedAgents);
const handleSearchChange = useCallback((query: string) => {
setSearchQuery(query);
}, []);
const handleFilterChange = useCallback((filter: AgentFilterTab) => {
setActiveFilter(filter);
}, []);
const isDataLoaded = useMemo(
(): Record<AgentSectionId, boolean> => ({
template: templateAgents !== null,
user: userAgents !== null,
shared: sharedAgents !== null,
}),
[templateAgents, userAgents, sharedAgents],
);
const totalAgentsBySection = useMemo(
(): Record<AgentSectionId, number> => ({
template: templateAgents?.length ?? 0,
user: userAgents?.length ?? 0,
shared: sharedAgents?.length ?? 0,
}),
[templateAgents, userAgents, sharedAgents],
);
const filteredAgentsBySection = useMemo((): AgentsBySection => {
const filtered = {
template: filterAgentsByQuery(templateAgents, searchQuery),
user: filterAgentsByQuery(userAgents, searchQuery),
shared: filterAgentsByQuery(sharedAgents, searchQuery),
};
if (activeFilter === 'all') {
return filtered;
}
return {
template: activeFilter === 'template' ? filtered.template : [],
user: activeFilter === 'user' ? filtered.user : [],
shared: activeFilter === 'shared' ? filtered.shared : [],
};
}, [templateAgents, userAgents, sharedAgents, searchQuery, activeFilter]);
const hasAnyAgents = useMemo(() => {
return (
totalAgentsBySection.template > 0 ||
totalAgentsBySection.user > 0 ||
totalAgentsBySection.shared > 0
);
}, [totalAgentsBySection]);
const hasFilteredResults = useMemo(() => {
return (
filteredAgentsBySection.template.length > 0 ||
filteredAgentsBySection.user.length > 0 ||
filteredAgentsBySection.shared.length > 0
);
}, [filteredAgentsBySection]);
return {
searchQuery,
setSearchQuery: handleSearchChange,
activeFilter,
setActiveFilter: handleFilterChange,
filteredAgentsBySection,
totalAgentsBySection,
hasAnyAgents,
hasFilteredResults,
isDataLoaded,
};
}

View File

@@ -0,0 +1,83 @@
import { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import {
selectToken,
setAgents,
setSharedAgents,
setTemplateAgents,
} from '../../preferences/preferenceSlice';
import { AgentSectionId } from '../agents.config';
interface UseAgentsFetchResult {
isLoading: Record<AgentSectionId, boolean>;
isAllLoaded: boolean;
}
export function useAgentsFetch(): UseAgentsFetchResult {
const dispatch = useDispatch();
const token = useSelector(selectToken);
const [isLoading, setIsLoading] = useState<Record<AgentSectionId, boolean>>({
template: true,
user: true,
shared: true,
});
const fetchTemplateAgents = useCallback(async () => {
try {
const response = await userService.getTemplateAgents(token);
if (!response.ok) throw new Error('Failed to fetch template agents');
const data = await response.json();
dispatch(setTemplateAgents(data));
} catch (error) {
dispatch(setTemplateAgents([]));
} finally {
setIsLoading((prev) => ({ ...prev, template: false }));
}
}, [token, dispatch]);
const fetchUserAgents = useCallback(async () => {
try {
const response = await userService.getAgents(token);
if (!response.ok) throw new Error('Failed to fetch user agents');
const data = await response.json();
dispatch(setAgents(data));
} catch (error) {
dispatch(setAgents([]));
} finally {
setIsLoading((prev) => ({ ...prev, user: false }));
}
}, [token, dispatch]);
const fetchSharedAgents = useCallback(async () => {
try {
const response = await userService.getSharedAgents(token);
if (!response.ok) throw new Error('Failed to fetch shared agents');
const data = await response.json();
dispatch(setSharedAgents(data));
} catch (error) {
dispatch(setSharedAgents([]));
} finally {
setIsLoading((prev) => ({ ...prev, shared: false }));
}
}, [token, dispatch]);
useEffect(() => {
setIsLoading({ template: true, user: true, shared: true });
Promise.all([
fetchTemplateAgents(),
fetchUserAgents(),
fetchSharedAgents(),
]);
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents]);
const isAllLoaded =
!isLoading.template && !isLoading.user && !isLoading.shared;
return {
isLoading,
isAllLoaded,
};
}

View File

@@ -491,6 +491,15 @@
"description": "Entdecke und erstelle benutzerdefinierte Versionen von DocsGPT, die Anweisungen, zusätzliches Wissen und beliebige Kombinationen von Fähigkeiten kombinieren",
"newAgent": "Neuer Agent",
"backToAll": "Zurück zu allen Agenten",
"searchPlaceholder": "Suchen...",
"noSearchResults": "Keine Agenten gefunden",
"tryDifferentSearch": "Versuche einen anderen Suchbegriff",
"filters": {
"all": "Alle",
"byDocsGPT": "Von DocsGPT",
"byMe": "Von mir",
"shared": "Mit mir geteilt"
},
"sections": {
"template": {
"title": "Von DocsGPT",

View File

@@ -491,6 +491,15 @@
"description": "Discover and create custom versions of DocsGPT that combine instructions, extra knowledge, and any combination of skills",
"newAgent": "New Agent",
"backToAll": "Back to all agents",
"searchPlaceholder": "Search...",
"noSearchResults": "No agents found",
"tryDifferentSearch": "Try a different search term",
"filters": {
"all": "All",
"byDocsGPT": "By DocsGPT",
"byMe": "By Me",
"shared": "Shared With Me"
},
"sections": {
"template": {
"title": "By DocsGPT",

View File

@@ -491,6 +491,15 @@
"description": "Descubre y crea versiones personalizadas de DocsGPT que combinan instrucciones, conocimiento adicional y cualquier combinación de habilidades",
"newAgent": "Nuevo Agente",
"backToAll": "Volver a todos los agentes",
"searchPlaceholder": "Buscar...",
"noSearchResults": "No se encontraron agentes",
"tryDifferentSearch": "Prueba con un término de búsqueda diferente",
"filters": {
"all": "Todos",
"byDocsGPT": "Por DocsGPT",
"byMe": "Por mí",
"shared": "Compartidos conmigo"
},
"sections": {
"template": {
"title": "Por DocsGPT",

View File

@@ -491,6 +491,15 @@
"description": "指示、追加知識、スキルの組み合わせを含むDocsGPTのカスタムバージョンを発見して作成します",
"newAgent": "新しいエージェント",
"backToAll": "すべてのエージェントに戻る",
"searchPlaceholder": "検索...",
"noSearchResults": "エージェントが見つかりません",
"tryDifferentSearch": "別の検索語をお試しください",
"filters": {
"all": "すべて",
"byDocsGPT": "DocsGPT提供",
"byMe": "自分の",
"shared": "共有された"
},
"sections": {
"template": {
"title": "DocsGPT提供",

View File

@@ -491,6 +491,15 @@
"description": "Откройте и создайте пользовательские версии DocsGPT, которые объединяют инструкции, дополнительные знания и любую комбинацию навыков",
"newAgent": "Новый Агент",
"backToAll": "Вернуться ко всем агентам",
"searchPlaceholder": "Поиск...",
"noSearchResults": "Агенты не найдены",
"tryDifferentSearch": "Попробуйте другой поисковый запрос",
"filters": {
"all": "Все",
"byDocsGPT": "От DocsGPT",
"byMe": "Мои",
"shared": "Поделились со мной"
},
"sections": {
"template": {
"title": "От DocsGPT",

View File

@@ -491,6 +491,15 @@
"description": "探索並創建結合指令、額外知識和任意技能組合的DocsGPT自訂版本",
"newAgent": "新建代理",
"backToAll": "返回所有代理",
"searchPlaceholder": "搜尋...",
"noSearchResults": "未找到代理",
"tryDifferentSearch": "請嘗試不同的搜尋詞",
"filters": {
"all": "全部",
"byDocsGPT": "由DocsGPT提供",
"byMe": "我的",
"shared": "與我共享"
},
"sections": {
"template": {
"title": "由DocsGPT提供",

View File

@@ -491,6 +491,15 @@
"description": "发现并创建结合指令、额外知识和任意技能组合的DocsGPT自定义版本",
"newAgent": "新建代理",
"backToAll": "返回所有代理",
"searchPlaceholder": "搜索...",
"noSearchResults": "未找到代理",
"tryDifferentSearch": "请尝试不同的搜索词",
"filters": {
"all": "全部",
"byDocsGPT": "由DocsGPT提供",
"byMe": "我的",
"shared": "与我共享"
},
"sections": {
"template": {
"title": "由DocsGPT提供",