Feat: Agents grouped under folders (#2245)

* chore(dependabot): add react-widget npm dependency updates

* refactor(prompts): init on load, mv to pref slice

* (refactor): searchable dropdowns are separate

* (fix/ui) prompts adjust

* feat(changelog): dancing stars

* (fix)conversation: re-blink bubble past stream

* (fix)endless GET sources, esling err

* (feat:Agents) folders metadata

* (feat:agents) create new folder

* (feat:agent-management) ui

* feat:(agent folders) nesting/sub-folders

* feat:(agent folders)- closer the figma, inline folder inputs

* fix(delete behaviour) refetch agents on delete

* (fix:search) folder context missing

* fix(newAgent) preserve folder context

* feat(agent folders) id preserved im query, navigate

* feat(agents) mobile responsive

* feat(search/agents) lookup for nested agents as well

* (fix/modals) close on outside click

---------

Co-authored-by: GH Action - Upstream Sync <action@github.com>
This commit is contained in:
Manish Madan
2026-01-08 22:16:40 +05:30
committed by GitHub
parent 7b17fde34a
commit 2246866a09
26 changed files with 1574 additions and 61 deletions

View File

@@ -1,10 +1,12 @@
import { SyntheticEvent, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import userService from '../api/services/userService';
import Duplicate from '../assets/duplicate.svg';
import Edit from '../assets/edit.svg';
import FolderIcon from '../assets/folder.svg';
import Link from '../assets/link-gray.svg';
import Monitoring from '../assets/monitoring.svg';
import Pin from '../assets/pin.svg';
@@ -14,6 +16,7 @@ import UnPin from '../assets/unpin.svg';
import AgentImage from '../components/AgentImage';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
import MoveToFolderModal from '../modals/MoveToFolderModal';
import { ActiveState } from '../models/misc';
import {
selectAgents,
@@ -36,6 +39,7 @@ export default function AgentCard({
updateAgents,
section,
}: AgentCardProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const token = useSelector(selectToken);
@@ -44,6 +48,7 @@ export default function AgentCard({
const [isMenuOpen, setIsMenuOpen] = useState<boolean>(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const [moveModalState, setMoveModalState] = useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
@@ -99,6 +104,18 @@ export default function AgentCard({
},
]
: []),
{
icon: FolderIcon,
label: t('agents.folders.moveToFolder'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setMoveModalState('ACTIVE');
setIsMenuOpen(false);
},
variant: 'primary',
iconWidth: 16,
iconHeight: 15,
},
{
icon: Trash,
label: 'Delete',
@@ -218,9 +235,19 @@ export default function AgentCard({
console.error('Error:', error);
}
};
const handleMoveSuccess = (folderId: string | null) => {
const updatedAgents = agents.map((prevAgent) => {
if (prevAgent.id === agent.id) {
return { ...prevAgent, folder_id: folderId ?? undefined };
}
return prevAgent;
});
updateAgents?.(updatedAgents);
};
return (
<div
className={`relative flex h-44 w-full flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-6 py-5 hover:bg-[#ECECEC] md:w-48 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
className={`relative flex h-44 flex-col justify-between rounded-[1.2rem] bg-[#F6F6F6] px-4 py-5 hover:bg-[#ECECEC] sm:w-48 sm:px-6 dark:bg-[#383838] dark:hover:bg-[#383838]/80 ${agent.status === 'published' && 'cursor-pointer'}`}
onClick={(e) => {
e.stopPropagation();
handleClick();
@@ -279,6 +306,14 @@ export default function AgentCard({
cancelLabel="Cancel"
variant="danger"
/>
<MoveToFolderModal
modalState={moveModalState}
setModalState={setMoveModalState}
agentName={agent.name}
agentId={agent.id ?? ''}
currentFolderId={agent.folder_id}
onMoveSuccess={handleMoveSuccess}
/>
</div>
);
}

View File

@@ -1,8 +1,9 @@
import { useEffect } from 'react';
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useSearchParams } from 'react-router-dom';
import userService from '../api/services/userService';
import Search from '../assets/search.svg';
import Spinner from '../components/Spinner';
import {
@@ -10,14 +11,18 @@ import {
updateConversationId,
} from '../conversation/conversationSlice';
import {
selectAgentFolders,
selectSelectedAgent,
selectToken,
setAgentFolders,
setSelectedAgent,
} from '../preferences/preferenceSlice';
import AgentCard from './AgentCard';
import { AgentSectionId, agentSectionsConfig } from './agents.config';
import FolderCard from './FolderCard';
import { AgentFilterTab, useAgentSearch } from './hooks/useAgentSearch';
import { useAgentsFetch } from './hooks/useAgentsFetch';
import { Agent } from './types';
import { Agent, AgentFolder } from './types';
const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
{ id: 'all', labelKey: 'agents.filters.all' },
@@ -29,9 +34,28 @@ const FILTER_TABS: { id: AgentFilterTab; labelKey: string }[] = [
export default function AgentsList() {
const { t } = useTranslation();
const dispatch = useDispatch();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const token = useSelector(selectToken);
const selectedAgent = useSelector(selectSelectedAgent);
const folders = useSelector(selectAgentFolders);
const [folderPath, setFolderPath] = useState<string[]>(() => {
const folderIdFromUrl = searchParams.get('folder');
return folderIdFromUrl ? [folderIdFromUrl] : [];
});
const { isLoading } = useAgentsFetch();
// Sync folder path with URL
useEffect(() => {
const currentFolderInUrl = searchParams.get('folder');
const currentFolderId = folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
if (currentFolderId !== currentFolderInUrl) {
const newUrl = currentFolderId ? `/agents?folder=${currentFolderId}` : '/agents';
navigate(newUrl, { replace: true });
}
}, [folderPath, searchParams, navigate]);
const { isLoading, refetchFolders, refetchUserAgents } = useAgentsFetch();
const {
searchQuery,
@@ -55,6 +79,57 @@ export default function AgentsList() {
if (selectedAgent) dispatch(setSelectedAgent(null));
}, []);
const handleCreateFolder = useCallback(
async (name: string, parentId?: string) => {
const response = await userService.createAgentFolder(
{ name, parent_id: parentId },
token,
);
if (response.ok) {
await refetchFolders();
return true;
}
return false;
},
[token, refetchFolders],
);
const handleDeleteFolder = useCallback(
async (folderId: string) => {
const response = await userService.deleteAgentFolder(folderId, token);
if (response.ok) {
await Promise.all([refetchFolders(), refetchUserAgents()]);
return true;
}
return false;
},
[token, refetchFolders, refetchUserAgents],
);
const handleRenameFolder = useCallback(
async (folderId: string, newName: string) => {
const response = await userService.updateAgentFolder(
folderId,
{ name: newName },
token,
);
if (response.ok) {
dispatch(
setAgentFolders(
(folders || []).map((f) =>
f.id === folderId ? { ...f, name: newName } : f,
),
),
);
}
},
[token, folders, dispatch],
);
const handleSubmitNewFolder = async (name: string, parentId?: string) => {
await handleCreateFolder(name, parentId);
};
const visibleSections = agentSectionsConfig.filter((config) => {
if (activeFilter !== 'all') {
return config.id === activeFilter;
@@ -97,7 +172,7 @@ export default function AgentsList() {
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]"
className="h-11 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>
@@ -109,7 +184,7 @@ export default function AgentsList() {
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'
: 'dark:text-gray bg-transparent text-[#71717A] hover:bg-[#F5F5F5] dark:hover:bg-[#383838]/50'
}`}
>
{t(tab.labelKey)}
@@ -129,6 +204,14 @@ export default function AgentsList() {
searchQuery={searchQuery}
isFilteredView={activeFilter !== 'all'}
isLoading={isLoading[sectionConfig.id as AgentSectionId]}
folders={sectionConfig.id === 'user' ? folders : null}
folderPath={sectionConfig.id === 'user' ? folderPath : []}
onFolderPathChange={
sectionConfig.id === 'user' ? setFolderPath : undefined
}
onCreateFolder={handleSubmitNewFolder}
onDeleteFolder={handleDeleteFolder}
onRenameFolder={handleRenameFolder}
/>
))}
@@ -149,6 +232,12 @@ interface AgentSectionProps {
searchQuery: string;
isFilteredView: boolean;
isLoading: boolean;
folders: AgentFolder[] | null;
folderPath: string[];
onFolderPathChange?: (path: string[]) => void;
onCreateFolder: (name: string, parentId?: string) => void;
onDeleteFolder: (id: string) => Promise<boolean>;
onRenameFolder: (id: string, name: string) => void;
}
function AgentSection({
@@ -158,15 +247,114 @@ function AgentSection({
searchQuery,
isFilteredView,
isLoading,
folders,
folderPath,
onFolderPathChange,
onCreateFolder,
onDeleteFolder,
onRenameFolder,
}: AgentSectionProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const dispatch = useDispatch();
const allAgents = useSelector(config.selectData);
const [isCreatingFolder, setIsCreatingFolder] = useState(false);
const [newFolderName, setNewFolderName] = useState('');
const newFolderInputRef = useRef<HTMLInputElement>(null);
const currentFolderId =
folderPath.length > 0 ? folderPath[folderPath.length - 1] : null;
const setFolderPath = useCallback(
(updater: string[] | ((prev: string[]) => string[])) => {
if (!onFolderPathChange) return;
if (typeof updater === 'function') {
onFolderPathChange(updater(folderPath));
} else {
onFolderPathChange(updater);
}
},
[onFolderPathChange, folderPath],
);
const updateAgents = (updatedAgents: Agent[]) => {
dispatch(config.updateAction(updatedAgents));
};
const currentFolderDescendantIds = useMemo(() => {
if (config.id !== 'user' || !folders || currentFolderId === null) return null;
const getDescendants = (folderId: string): string[] => {
const children = folders.filter((f) => f.parent_id === folderId);
return children.flatMap((child) => [child.id, ...getDescendants(child.id)]);
};
return new Set([currentFolderId, ...getDescendants(currentFolderId)]);
}, [folders, currentFolderId, config.id]);
const folderHasMatchingAgents = useCallback(
(folderId: string): boolean => {
const directMatches = filteredAgents.some((a) => a.folder_id === folderId);
if (directMatches) return true;
const childFolders = (folders || []).filter(
(f) => f.parent_id === folderId,
);
return childFolders.some((f) => folderHasMatchingAgents(f.id));
},
[filteredAgents, folders],
);
// Get folders at the current level (root or inside current folder)
const currentLevelFolders = useMemo(() => {
if (config.id !== 'user' || !folders) return [];
const foldersAtLevel = folders.filter(
(f) => (f.parent_id || null) === currentFolderId,
);
if (searchQuery) {
return foldersAtLevel.filter((f) => folderHasMatchingAgents(f.id));
}
return foldersAtLevel;
}, [folders, currentFolderId, config.id, searchQuery, folderHasMatchingAgents]);
const unfolderedAgents = useMemo(() => {
if (config.id !== 'user' || !folders) return filteredAgents;
if (searchQuery) {
// When searching at root: return ALL filtered agents
if (currentFolderId === null) {
return filteredAgents;
}
// When searching inside a folder: return agents in current folder OR any descendant
return filteredAgents.filter(
(a) => currentFolderDescendantIds?.has(a.folder_id ?? '') ?? false,
);
}
// No search: show agents that belong to the current folder level only
return filteredAgents.filter(
(a) => (a.folder_id || null) === currentFolderId,
);
}, [filteredAgents, folders, config.id, currentFolderId, searchQuery, currentFolderDescendantIds]);
const getAgentsForFolder = (folderId: string) => {
return filteredAgents.filter((a) => a.folder_id === folderId);
};
const handleNavigateIntoFolder = (folderId: string) => {
setFolderPath((prev) => [...prev, folderId]);
};
const handleNavigateToPath = (index: number) => {
if (index < 0) {
setFolderPath([]);
} else {
setFolderPath((prev) => prev.slice(0, index + 1));
}
};
const handleSubmitNewFolder = (name: string) => {
onCreateFolder(name, currentFolderId || undefined);
};
const hasNoAgentsAtAll = !isLoading && totalAgents === 0;
const isSearchingWithNoResults =
@@ -197,56 +385,187 @@ function AgentSection({
);
}
// Build breadcrumb items from folder path
const breadcrumbItems = useMemo(() => {
if (!folders || folderPath.length === 0) return [];
return folderPath.map((folderId) => {
const folder = folders.find((f) => f.id === folderId);
return { id: folderId, name: folder?.name || '' };
});
}, [folders, folderPath]);
const ChevronIcon = () => (
<svg
width="6"
height="10"
viewBox="0 0 6 10"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M5.54027 4.45973C5.68108 4.60058 5.76018 4.79159 5.76018 4.99075C5.76018 5.18992 5.68108 5.38092 5.54027 5.52177L1.29134 9.7707C1.22206 9.84244 1.13918 9.89966 1.04754 9.93902C0.955906 9.97839 0.857348 9.9991 0.757618 9.99997C0.657889 10.0008 0.558986 9.98183 0.466679 9.94407C0.374373 9.9063 0.290512 9.85053 0.21999 9.78001C0.149467 9.70949 0.0936966 9.62563 0.055931 9.53332C0.0181655 9.44101 -0.000838292 9.34211 2.83259e-05 9.24238C0.000894943 9.14265 0.0216148 9.04409 0.0609787 8.95246C0.100343 8.86082 0.157562 8.77794 0.229299 8.70866L3.9472 4.99075L0.229299 1.27285C0.0924814 1.13119 0.0167756 0.941464 0.0184869 0.744531C0.0201982 0.547597 0.0991896 0.359213 0.238448 0.219954C0.377707 0.0806961 0.56609 0.00170419 0.763024 -7.66275e-06C0.959958 -0.00171856 1.14969 0.073987 1.29134 0.210805L5.54027 4.45973Z"
fill="currentColor"
fillOpacity="0.5"
/>
</svg>
);
return (
<div className="mt-8 flex flex-col gap-4">
<div className="flex w-full items-center justify-between">
<div className="flex w-full flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-2">
<h2 className="text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
{t(`agents.sections.${config.id}.title`)}
<h2 className="flex flex-wrap items-center gap-2 text-[18px] font-semibold text-[#18181B] dark:text-[#E0E0E0]">
{config.id === 'user' && folderPath.length > 0 ? (
<>
<button
onClick={() => handleNavigateToPath(-1)}
className="text-[#71717A] hover:text-[#18181B] dark:hover:text-white"
>
{t(`agents.sections.${config.id}.title`)}
</button>
{breadcrumbItems.map((item, index) => (
<span key={item.id} className="flex items-center gap-2">
<ChevronIcon />
{index === breadcrumbItems.length - 1 ? (
<span>{item.name}</span>
) : (
<button
onClick={() => handleNavigateToPath(index)}
className="text-[#71717A] hover:text-[#18181B] dark:hover:text-white"
>
{item.name}
</button>
)}
</span>
))}
</>
) : (
t(`agents.sections.${config.id}.title`)
)}
</h2>
<p className="text-[13px] text-[#71717A]">
{t(`agents.sections.${config.id}.description`)}
</p>
</div>
{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 className="flex items-center gap-2">
{config.id === 'user' &&
(isCreatingFolder ? (
<input
ref={newFolderInputRef}
type="text"
value={newFolderName}
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && newFolderName.trim()) {
handleSubmitNewFolder(newFolderName.trim());
setNewFolderName('');
setIsCreatingFolder(false);
} else if (e.key === 'Escape') {
setNewFolderName('');
setIsCreatingFolder(false);
}
}}
onBlur={() => {
if (!newFolderName.trim()) {
setIsCreatingFolder(false);
}
}}
placeholder={t('agents.folders.newFolder')}
className="w-28 rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] outline-none placeholder:text-[#9CA3AF] sm:w-auto dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:placeholder:text-[#6B7280]"
autoFocus
/>
) : (
<button
className="shrink-0 whitespace-nowrap rounded-full border border-[#E5E5E5] bg-white px-4 py-2 text-sm text-[#18181B] hover:bg-[#F5F5F5] dark:border-[#3A3A3A] dark:bg-[#2C2C2C] dark:text-white dark:hover:bg-[#383838]"
onClick={() => {
setIsCreatingFolder(true);
setTimeout(() => newFolderInputRef.current?.focus(), 0);
}}
>
{t('agents.folders.newFolder')}
</button>
))}
{config.showNewAgentButton && (
<button
className="bg-purple-30 hover:bg-violets-are-blue shrink-0 whitespace-nowrap rounded-full px-4 py-2 text-sm text-white"
onClick={() =>
navigate(
currentFolderId
? `/agents/new?folder_id=${currentFolderId}`
: '/agents/new',
)
}
>
{t('agents.newAgent')}
</button>
)}
</div>
</div>
<div>
<div className="flex flex-col gap-4">
{isLoading ? (
<div className="flex h-40 w-full items-center justify-center">
<Spinner />
</div>
) : filteredAgents.length > 0 ? (
<div className="grid grid-cols-1 gap-4 sm:flex sm:flex-wrap">
{filteredAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
agents={allAgents || []}
updateAgents={updateAgents}
section={config.id}
/>
))}
</div>
) : 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
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() => navigate('/agents/new')}
>
{t('agents.newAgent')}
</button>
) : (
<>
{/* Show subfolders at current level */}
{config.id === 'user' && currentLevelFolders.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
{currentLevelFolders.map((folder) => (
<FolderCard
key={folder.id}
folder={folder}
agentCount={getAgentsForFolder(folder.id).length}
onDelete={onDeleteFolder}
onRename={onRenameFolder}
isExpanded={false}
onToggleExpand={handleNavigateIntoFolder}
/>
))}
</div>
)}
</div>
) : null}
{/* Show agents at current level */}
{unfolderedAgents.length > 0 ? (
<div className="grid grid-cols-2 gap-3 sm:flex sm:flex-wrap">
{unfolderedAgents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
agents={allAgents || []}
updateAgents={updateAgents}
section={config.id}
/>
))}
</div>
) : hasNoAgentsAtAll && currentLevelFolders.length === 0 ? (
<div className="flex h-40 w-full flex-col items-center justify-center gap-3 text-[#71717A]">
<p>
{currentFolderId
? t('agents.folders.empty')
: t(`agents.sections.${config.id}.emptyState`)}
</p>
{config.showNewAgentButton && !currentFolderId && (
<button
className="bg-purple-30 hover:bg-violets-are-blue ml-2 rounded-full px-4 py-2 text-sm text-white"
onClick={() =>
navigate(
currentFolderId
? `/agents/new?folder_id=${currentFolderId}`
: '/agents/new',
)
}
>
{t('agents.newAgent')}
</button>
)}
</div>
) : null}
</>
)}
</div>
</div>
);

View File

@@ -0,0 +1,128 @@
import { SyntheticEvent, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Edit from '../assets/edit.svg';
import Trash from '../assets/red-trash.svg';
import ThreeDots from '../assets/three-dots.svg';
import ContextMenu, { MenuOption } from '../components/ContextMenu';
import ConfirmationModal from '../modals/ConfirmationModal';
import FolderNameModal from '../modals/FolderManagementModal';
import { ActiveState } from '../models/misc';
import { AgentFolder } from './types';
type FolderCardProps = {
folder: AgentFolder;
agentCount: number;
onDelete: (folderId: string) => Promise<boolean>;
onRename: (folderId: string, newName: string) => void;
isExpanded: boolean;
onToggleExpand: (folderId: string) => void;
};
export default function FolderCard({
folder,
agentCount,
onDelete,
onRename,
isExpanded,
onToggleExpand,
}: FolderCardProps) {
const { t } = useTranslation();
const [isMenuOpen, setIsMenuOpen] = useState(false);
const [deleteConfirmation, setDeleteConfirmation] =
useState<ActiveState>('INACTIVE');
const [renameModalState, setRenameModalState] =
useState<ActiveState>('INACTIVE');
const menuRef = useRef<HTMLDivElement>(null);
const menuOptions: MenuOption[] = [
{
icon: Edit,
label: t('agents.folders.rename'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setRenameModalState('ACTIVE');
setIsMenuOpen(false);
},
variant: 'primary',
iconWidth: 14,
iconHeight: 14,
},
{
icon: Trash,
label: t('agents.folders.delete'),
onClick: (e: SyntheticEvent) => {
e.stopPropagation();
setDeleteConfirmation('ACTIVE');
setIsMenuOpen(false);
},
variant: 'danger',
iconWidth: 13,
iconHeight: 13,
},
];
const handleRename = (newName: string) => {
onRename(folder.id, newName);
};
return (
<>
<div
className={`relative flex cursor-pointer items-center justify-between rounded-[1.2rem] px-4 py-3 sm:w-48 ${
isExpanded
? 'bg-[#E5E5E5] dark:bg-[#454545]'
: 'bg-[#F6F6F6] hover:bg-[#ECECEC] dark:bg-[#383838] dark:hover:bg-[#383838]/80'
}`}
onClick={() => onToggleExpand(folder.id)}
>
<div className="flex items-center gap-2 overflow-hidden">
<span className="truncate text-sm font-medium text-[#18181B] dark:text-[#E0E0E0]">
{folder.name}
</span>
<span className="shrink-0 text-xs text-[#71717A]">
({agentCount})
</span>
</div>
<div
ref={menuRef}
onClick={(e) => {
e.stopPropagation();
setIsMenuOpen(true);
}}
className="ml-2 shrink-0 cursor-pointer"
>
<img src={ThreeDots} alt="menu" className="h-4 w-4" />
<ContextMenu
isOpen={isMenuOpen}
setIsOpen={setIsMenuOpen}
options={menuOptions}
anchorRef={menuRef}
position="bottom-right"
offset={{ x: 0, y: 0 }}
/>
</div>
</div>
<ConfirmationModal
message={t('agents.folders.deleteConfirm')}
modalState={deleteConfirmation}
setModalState={setDeleteConfirmation}
submitLabel={t('convTile.delete')}
handleSubmit={() => {
onDelete(folder.id);
setDeleteConfirmation('INACTIVE');
}}
cancelLabel={t('cancel')}
variant="danger"
/>
<FolderNameModal
modalState={renameModalState}
setModalState={setRenameModalState}
mode="rename"
initialName={folder.name}
onSubmit={handleRename}
/>
</>
);
}

View File

@@ -2,7 +2,7 @@ import isEqual from 'lodash/isEqual';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { useNavigate, useParams } from 'react-router-dom';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import modelService from '../api/services/modelService';
import userService from '../api/services/userService';
@@ -16,10 +16,12 @@ import AgentDetailsModal from '../modals/AgentDetailsModal';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState, Doc, Prompt } from '../models/misc';
import {
selectAgentFolders,
selectSelectedAgent,
selectSourceDocs,
selectToken,
selectPrompts,
setAgentFolders,
setSelectedAgent,
setPrompts,
} from '../preferences/preferenceSlice';
@@ -37,10 +39,16 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
const dispatch = useDispatch();
const { agentId } = useParams();
const [searchParams] = useSearchParams();
const folderIdFromUrl = searchParams.get('folder_id');
const token = useSelector(selectToken);
const sourceDocs = useSelector(selectSourceDocs);
const selectedAgent = useSelector(selectSelectedAgent);
const prompts = useSelector(selectPrompts);
const agentFolders = useSelector(selectAgentFolders);
const [validatedFolderId, setValidatedFolderId] = useState<string | null>(null);
const [effectiveMode, setEffectiveMode] = useState(mode);
const [agent, setAgent] = useState<Agent>({
@@ -149,15 +157,22 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
}
}, []);
const navigateBackToAgents = useCallback(() => {
const targetPath = validatedFolderId
? `/agents?folder=${validatedFolderId}`
: '/agents';
navigate(targetPath);
}, [navigate, validatedFolderId]);
const handleCancel = () => {
if (selectedAgent) dispatch(setSelectedAgent(null));
navigate('/agents');
navigateBackToAgents();
};
const handleDelete = async (agentId: string) => {
const response = await userService.deleteAgent(agentId, token);
if (!response.ok) throw new Error('Failed to delete agent');
navigate('/agents');
navigateBackToAgents();
};
const handleSaveDraft = async () => {
@@ -238,6 +253,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('default_model_id', agent.default_model_id);
}
if (effectiveMode === 'new' && validatedFolderId) {
formData.append('folder_id', validatedFolderId);
}
try {
setDraftLoading(true);
const response =
@@ -341,6 +360,10 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
formData.append('default_model_id', agent.default_model_id);
}
if (effectiveMode === 'new' && validatedFolderId) {
formData.append('folder_id', validatedFolderId);
}
try {
setPublishLoading(true);
const response =
@@ -412,6 +435,36 @@ export default function NewAgent({ mode }: { mode: 'new' | 'edit' | 'draft' }) {
getModels();
}, [token]);
// Validate folder_id from URL against user's folders
useEffect(() => {
const validateAndSetFolder = async () => {
if (!folderIdFromUrl) {
setValidatedFolderId(null);
return;
}
let folders = agentFolders;
if (!folders) {
try {
const response = await userService.getAgentFolders(token);
if (response.ok) {
const data = await response.json();
folders = data.folders || [];
dispatch(setAgentFolders(folders));
}
} catch {
setValidatedFolderId(null);
return;
}
}
const folderExists = folders?.some((f) => f.id === folderIdFromUrl);
setValidatedFolderId(folderExists ? folderIdFromUrl : null);
};
validateAndSetFolder();
}, [folderIdFromUrl, agentFolders, token, dispatch]);
// Auto-select default source if none selected
useEffect(() => {
if (sourceDocs && sourceDocs.length > 0 && selectedSourceIds.size === 0) {

View File

@@ -4,6 +4,7 @@ import { useDispatch, useSelector } from 'react-redux';
import userService from '../../api/services/userService';
import {
selectToken,
setAgentFolders,
setAgents,
setSharedAgents,
setTemplateAgents,
@@ -13,6 +14,8 @@ import { AgentSectionId } from '../agents.config';
interface UseAgentsFetchResult {
isLoading: Record<AgentSectionId, boolean>;
isAllLoaded: boolean;
refetchFolders: () => Promise<void>;
refetchUserAgents: () => Promise<void>;
}
export function useAgentsFetch(): UseAgentsFetchResult {
@@ -64,14 +67,26 @@ export function useAgentsFetch(): UseAgentsFetchResult {
}
}, [token, dispatch]);
const fetchFolders = useCallback(async () => {
try {
const response = await userService.getAgentFolders(token);
if (!response.ok) throw new Error('Failed to fetch folders');
const data = await response.json();
dispatch(setAgentFolders(data.folders || []));
} catch (error) {
dispatch(setAgentFolders([]));
}
}, [token, dispatch]);
useEffect(() => {
setIsLoading({ template: true, user: true, shared: true });
Promise.all([
fetchTemplateAgents(),
fetchUserAgents(),
fetchSharedAgents(),
fetchFolders(),
]);
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents]);
}, [fetchTemplateAgents, fetchUserAgents, fetchSharedAgents, fetchFolders]);
const isAllLoaded =
!isLoading.template && !isLoading.user && !isLoading.shared;
@@ -79,5 +94,7 @@ export function useAgentsFetch(): UseAgentsFetchResult {
return {
isLoading,
isAllLoaded,
refetchFolders: fetchFolders,
refetchUserAgents: fetchUserAgents,
};
}

View File

@@ -34,4 +34,13 @@ export type Agent = {
request_limit?: number;
models?: string[];
default_model_id?: string;
folder_id?: string;
};
export type AgentFolder = {
id: string;
name: string;
parent_id?: string | null;
created_at?: string;
updated_at?: string;
};