From 9b9f95710a2d7654694ed4ba1d98724586f9cae4 Mon Sep 17 00:00:00 2001 From: Siddhant Rai <47355538+siiddhantt@users.noreply.github.com> Date: Thu, 4 Dec 2025 21:16:37 +0530 Subject: [PATCH] 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 --- frontend/src/agents/AgentsList.tsx | 197 ++++++++++++++++---- frontend/src/agents/agents.config.ts | 12 +- frontend/src/agents/hooks/useAgentSearch.ts | 122 ++++++++++++ frontend/src/agents/hooks/useAgentsFetch.ts | 83 +++++++++ frontend/src/locale/de.json | 9 + frontend/src/locale/en.json | 9 + frontend/src/locale/es.json | 9 + frontend/src/locale/jp.json | 9 + frontend/src/locale/ru.json | 9 + frontend/src/locale/zh-TW.json | 9 + frontend/src/locale/zh.json | 9 + 11 files changed, 431 insertions(+), 46 deletions(-) create mode 100644 frontend/src/agents/hooks/useAgentSearch.ts create mode 100644 frontend/src/agents/hooks/useAgentsFetch.ts diff --git a/frontend/src/agents/AgentsList.tsx b/frontend/src/agents/AgentsList.tsx index bdccb0f1..0c3c1d0e 100644 --- a/frontend/src/agents/AgentsList.tsx +++ b/frontend/src/agents/AgentsList.tsx @@ -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 (

{t('agents.title')}

-

+

{t('agents.description')}

- {agentSectionsConfig.map((sectionConfig) => ( - + +
+
+ + 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]" + /> +
+ +
+ {FILTER_TABS.map((tab) => ( + + ))} +
+
+ + {visibleSections.map((sectionConfig) => ( + ))} + + {showSearchEmptyState && ( +
+

{t('agents.noSearchResults')}

+

{t('agents.tryDifferentSearch')}

+
+ )}
); } +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 ( +
+

{t('agents.noSearchResults')}

+

{t('agents.tryDifferentSearch')}

+
+ ); + } + + if (isFilteredView && hasNoAgentsAtAll) { + return ( +
+

{t(`agents.sections.${config.id}.emptyState`)}

+ {config.showNewAgentButton && ( + + )} +
+ ); + } + return (
@@ -103,24 +218,24 @@ function AgentSection({ )}
- {loading ? ( -
+ {isLoading ? ( +
- ) : agents && agents.length > 0 ? ( + ) : filteredAgents.length > 0 ? (
- {agents.map((agent) => ( + {filteredAgents.map((agent) => ( ))}
- ) : ( -
+ ) : hasNoAgentsAtAll ? ( +

{t(`agents.sections.${config.id}.emptyState`)}

{config.showNewAgentButton && (
- )} + ) : null}
); diff --git a/frontend/src/agents/agents.config.ts b/frontend/src/agents/agents.config.ts index b6be5015..3569600e 100644 --- a/frontend/src/agents/agents.config.ts +++ b/frontend/src/agents/agents.config.ts @@ -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, diff --git a/frontend/src/agents/hooks/useAgentSearch.ts b/frontend/src/agents/hooks/useAgentSearch.ts new file mode 100644 index 00000000..7a5edb9e --- /dev/null +++ b/frontend/src/agents/hooks/useAgentSearch.ts @@ -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; + +interface UseAgentSearchResult { + searchQuery: string; + setSearchQuery: (query: string) => void; + activeFilter: AgentFilterTab; + setActiveFilter: (filter: AgentFilterTab) => void; + filteredAgentsBySection: AgentsBySection; + totalAgentsBySection: Record; + hasAnyAgents: boolean; + hasFilteredResults: boolean; + isDataLoaded: Record; +} + +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('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 => ({ + template: templateAgents !== null, + user: userAgents !== null, + shared: sharedAgents !== null, + }), + [templateAgents, userAgents, sharedAgents], + ); + + const totalAgentsBySection = useMemo( + (): Record => ({ + 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, + }; +} diff --git a/frontend/src/agents/hooks/useAgentsFetch.ts b/frontend/src/agents/hooks/useAgentsFetch.ts new file mode 100644 index 00000000..8f5367d6 --- /dev/null +++ b/frontend/src/agents/hooks/useAgentsFetch.ts @@ -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; + isAllLoaded: boolean; +} + +export function useAgentsFetch(): UseAgentsFetchResult { + const dispatch = useDispatch(); + const token = useSelector(selectToken); + + const [isLoading, setIsLoading] = useState>({ + 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, + }; +} diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index a4b10003..c4b58a3e 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -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", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 69bde744..dbb8bdbe 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -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", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 65eea4c9..49f09344 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -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", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 6be92551..b8458d2c 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -491,6 +491,15 @@ "description": "指示、追加知識、スキルの組み合わせを含むDocsGPTのカスタムバージョンを発見して作成します", "newAgent": "新しいエージェント", "backToAll": "すべてのエージェントに戻る", + "searchPlaceholder": "検索...", + "noSearchResults": "エージェントが見つかりません", + "tryDifferentSearch": "別の検索語をお試しください", + "filters": { + "all": "すべて", + "byDocsGPT": "DocsGPT提供", + "byMe": "自分の", + "shared": "共有された" + }, "sections": { "template": { "title": "DocsGPT提供", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index fe136ee7..103f3627 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -491,6 +491,15 @@ "description": "Откройте и создайте пользовательские версии DocsGPT, которые объединяют инструкции, дополнительные знания и любую комбинацию навыков", "newAgent": "Новый Агент", "backToAll": "Вернуться ко всем агентам", + "searchPlaceholder": "Поиск...", + "noSearchResults": "Агенты не найдены", + "tryDifferentSearch": "Попробуйте другой поисковый запрос", + "filters": { + "all": "Все", + "byDocsGPT": "От DocsGPT", + "byMe": "Мои", + "shared": "Поделились со мной" + }, "sections": { "template": { "title": "От DocsGPT", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 24c8d273..47c0b236 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -491,6 +491,15 @@ "description": "探索並創建結合指令、額外知識和任意技能組合的DocsGPT自訂版本", "newAgent": "新建代理", "backToAll": "返回所有代理", + "searchPlaceholder": "搜尋...", + "noSearchResults": "未找到代理", + "tryDifferentSearch": "請嘗試不同的搜尋詞", + "filters": { + "all": "全部", + "byDocsGPT": "由DocsGPT提供", + "byMe": "我的", + "shared": "與我共享" + }, "sections": { "template": { "title": "由DocsGPT提供", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index c14c4e74..12b867fa 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -491,6 +491,15 @@ "description": "发现并创建结合指令、额外知识和任意技能组合的DocsGPT自定义版本", "newAgent": "新建代理", "backToAll": "返回所有代理", + "searchPlaceholder": "搜索...", + "noSearchResults": "未找到代理", + "tryDifferentSearch": "请尝试不同的搜索词", + "filters": { + "all": "全部", + "byDocsGPT": "由DocsGPT提供", + "byMe": "我的", + "shared": "与我共享" + }, "sections": { "template": { "title": "由DocsGPT提供",