| 'use client' | |
| import { Button } from '@/components/ui/button' | |
| import { ModeSelector } from '@/components/chat/Sidebar/ModeSelector' | |
| import { EntitySelector } from '@/components/chat/Sidebar/EntitySelector' | |
| import useChatActions from '@/hooks/useChatActions' | |
| import { useStore } from '@/store' | |
| import { motion, AnimatePresence } from 'framer-motion' | |
| import { useState, useEffect } from 'react' | |
| import Icon from '@/components/ui/icon' | |
| import { getProviderIcon } from '@/lib/modelProvider' | |
| import Sessions from './Sessions' | |
| import { isValidUrl } from '@/lib/utils' | |
| import { toast } from 'sonner' | |
| import { useQueryState } from 'nuqs' | |
| import { truncateText } from '@/lib/utils' | |
| import { Skeleton } from '@/components/ui/skeleton' | |
| const ENDPOINT_PLACEHOLDER = 'NO ENDPOINT ADDED' | |
| const SidebarHeader = () => ( | |
| <div className="flex items-center gap-2"> | |
| <Icon type="agno" size="xs" /> | |
| <span className="text-xs font-medium uppercase text-white">Agent UI</span> | |
| </div> | |
| ) | |
| const NewChatButton = ({ | |
| disabled, | |
| onClick | |
| }: { | |
| disabled: boolean | |
| onClick: () => void | |
| }) => ( | |
| <Button | |
| onClick={onClick} | |
| disabled={disabled} | |
| size="lg" | |
| className="h-9 w-full rounded-xl bg-primary text-xs font-medium text-background hover:bg-primary/80" | |
| > | |
| <Icon type="plus-icon" size="xs" className="text-background" /> | |
| <span className="uppercase">New Chat</span> | |
| </Button> | |
| ) | |
| const ModelDisplay = ({ model }: { model: string }) => ( | |
| <div className="flex h-9 w-full items-center gap-3 rounded-xl border border-primary/15 bg-accent p-3 text-xs font-medium uppercase text-muted"> | |
| {(() => { | |
| const icon = getProviderIcon(model) | |
| return icon ? <Icon type={icon} className="shrink-0" size="xs" /> : null | |
| })()} | |
| {model} | |
| </div> | |
| ) | |
| const Endpoint = () => { | |
| const { | |
| selectedEndpoint, | |
| isEndpointActive, | |
| setSelectedEndpoint, | |
| setAgents, | |
| setSessionsData, | |
| setMessages | |
| } = useStore() | |
| const { initialize } = useChatActions() | |
| const [isEditing, setIsEditing] = useState(false) | |
| const [endpointValue, setEndpointValue] = useState('') | |
| const [isMounted, setIsMounted] = useState(false) | |
| const [isHovering, setIsHovering] = useState(false) | |
| const [isRotating, setIsRotating] = useState(false) | |
| const [, setAgentId] = useQueryState('agent') | |
| const [, setSessionId] = useQueryState('session') | |
| useEffect(() => { | |
| setEndpointValue(selectedEndpoint) | |
| setIsMounted(true) | |
| }, [selectedEndpoint]) | |
| const getStatusColor = (isActive: boolean) => | |
| isActive ? 'bg-positive' : 'bg-destructive' | |
| const handleSave = async () => { | |
| if (!isValidUrl(endpointValue)) { | |
| toast.error('Please enter a valid URL') | |
| return | |
| } | |
| const cleanEndpoint = endpointValue.replace(/\/$/, '').trim() | |
| setSelectedEndpoint(cleanEndpoint) | |
| setAgentId(null) | |
| setSessionId(null) | |
| setIsEditing(false) | |
| setIsHovering(false) | |
| setAgents([]) | |
| setSessionsData([]) | |
| setMessages([]) | |
| } | |
| const handleCancel = () => { | |
| setEndpointValue(selectedEndpoint) | |
| setIsEditing(false) | |
| setIsHovering(false) | |
| } | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => { | |
| if (e.key === 'Enter') { | |
| handleSave() | |
| } else if (e.key === 'Escape') { | |
| handleCancel() | |
| } | |
| } | |
| const handleRefresh = async () => { | |
| setIsRotating(true) | |
| await initialize() | |
| setTimeout(() => setIsRotating(false), 500) | |
| } | |
| return ( | |
| <div className="flex flex-col items-start gap-2"> | |
| <div className="text-xs font-medium uppercase text-primary">AgentOS</div> | |
| {isEditing ? ( | |
| <div className="flex w-full items-center gap-1"> | |
| <input | |
| type="text" | |
| value={endpointValue} | |
| onChange={(e) => setEndpointValue(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| className="flex h-9 w-full items-center text-ellipsis rounded-xl border border-primary/15 bg-accent p-3 text-xs font-medium text-muted" | |
| autoFocus | |
| /> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleSave} | |
| className="hover:cursor-pointer hover:bg-transparent" | |
| > | |
| <Icon type="save" size="xs" /> | |
| </Button> | |
| </div> | |
| ) : ( | |
| <div className="flex w-full items-center gap-1"> | |
| <motion.div | |
| className="relative flex h-9 w-full cursor-pointer items-center justify-between rounded-xl border border-primary/15 bg-accent p-3 uppercase" | |
| onMouseEnter={() => setIsHovering(true)} | |
| onMouseLeave={() => setIsHovering(false)} | |
| onClick={() => setIsEditing(true)} | |
| transition={{ type: 'spring', stiffness: 400, damping: 10 }} | |
| > | |
| <AnimatePresence mode="wait"> | |
| {isHovering ? ( | |
| <motion.div | |
| key="endpoint-display-hover" | |
| className="absolute inset-0 flex items-center justify-center" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| > | |
| <p className="flex items-center gap-2 whitespace-nowrap text-xs font-medium text-primary"> | |
| <Icon type="edit" size="xxs" /> EDIT AGENTOS | |
| </p> | |
| </motion.div> | |
| ) : ( | |
| <motion.div | |
| key="endpoint-display" | |
| className="absolute inset-0 flex items-center justify-between px-3" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| exit={{ opacity: 0 }} | |
| transition={{ duration: 0.2 }} | |
| > | |
| <p className="text-xs font-medium text-muted"> | |
| {isMounted | |
| ? truncateText(selectedEndpoint, 21) || | |
| ENDPOINT_PLACEHOLDER | |
| : 'http://localhost:7777'} | |
| </p> | |
| <div | |
| className={`size-2 shrink-0 rounded-full ${getStatusColor(isEndpointActive)}`} | |
| /> | |
| </motion.div> | |
| )} | |
| </AnimatePresence> | |
| </motion.div> | |
| <Button | |
| variant="ghost" | |
| size="icon" | |
| onClick={handleRefresh} | |
| className="hover:cursor-pointer hover:bg-transparent" | |
| > | |
| <motion.div | |
| key={isRotating ? 'rotating' : 'idle'} | |
| animate={{ rotate: isRotating ? 360 : 0 }} | |
| transition={{ duration: 0.5, ease: 'easeInOut' }} | |
| > | |
| <Icon type="refresh" size="xs" /> | |
| </motion.div> | |
| </Button> | |
| </div> | |
| )} | |
| </div> | |
| ) | |
| } | |
| const Sidebar = () => { | |
| const [isCollapsed, setIsCollapsed] = useState(false) | |
| const { clearChat, focusChatInput, initialize } = useChatActions() | |
| const { | |
| messages, | |
| selectedEndpoint, | |
| isEndpointActive, | |
| selectedModel, | |
| hydrated, | |
| isEndpointLoading, | |
| mode | |
| } = useStore() | |
| const [isMounted, setIsMounted] = useState(false) | |
| const [agentId] = useQueryState('agent') | |
| const [teamId] = useQueryState('team') | |
| useEffect(() => { | |
| setIsMounted(true) | |
| if (hydrated) initialize() | |
| }, [selectedEndpoint, initialize, hydrated, mode]) | |
| const handleNewChat = () => { | |
| clearChat() | |
| focusChatInput() | |
| } | |
| return ( | |
| <motion.aside | |
| className="relative flex h-screen shrink-0 grow-0 flex-col overflow-hidden px-2 py-3 font-dmmono" | |
| initial={{ width: '16rem' }} | |
| animate={{ width: isCollapsed ? '2.5rem' : '16rem' }} | |
| transition={{ type: 'spring', stiffness: 300, damping: 30 }} | |
| > | |
| <motion.button | |
| onClick={() => setIsCollapsed(!isCollapsed)} | |
| className="absolute right-2 top-2 z-10 p-1" | |
| aria-label={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'} | |
| type="button" | |
| whileTap={{ scale: 0.95 }} | |
| > | |
| <Icon | |
| type="sheet" | |
| size="xs" | |
| className={`transform ${isCollapsed ? 'rotate-180' : 'rotate-0'}`} | |
| /> | |
| </motion.button> | |
| <motion.div | |
| className="w-60 space-y-5" | |
| initial={{ opacity: 0, x: -20 }} | |
| animate={{ opacity: isCollapsed ? 0 : 1, x: isCollapsed ? -20 : 0 }} | |
| transition={{ duration: 0.3, ease: 'easeInOut' }} | |
| style={{ | |
| pointerEvents: isCollapsed ? 'none' : 'auto' | |
| }} | |
| > | |
| <SidebarHeader /> | |
| <NewChatButton | |
| disabled={messages.length === 0} | |
| onClick={handleNewChat} | |
| /> | |
| {isMounted && ( | |
| <> | |
| <Endpoint /> | |
| {isEndpointActive && ( | |
| <> | |
| <motion.div | |
| className="flex w-full flex-col items-start gap-2" | |
| initial={{ opacity: 0 }} | |
| animate={{ opacity: 1 }} | |
| transition={{ duration: 0.5, ease: 'easeInOut' }} | |
| > | |
| <div className="text-xs font-medium uppercase text-primary"> | |
| Mode | |
| </div> | |
| {isEndpointLoading ? ( | |
| <div className="flex w-full flex-col gap-2"> | |
| {Array.from({ length: 3 }).map((_, index) => ( | |
| <Skeleton | |
| key={index} | |
| className="h-9 w-full rounded-xl" | |
| /> | |
| ))} | |
| </div> | |
| ) : ( | |
| <> | |
| <ModeSelector /> | |
| <EntitySelector /> | |
| {selectedModel && (agentId || teamId) && ( | |
| <ModelDisplay model={selectedModel} /> | |
| )} | |
| </> | |
| )} | |
| </motion.div> | |
| <Sessions /> | |
| </> | |
| )} | |
| </> | |
| )} | |
| </motion.div> | |
| </motion.aside> | |
| ) | |
| } | |
| export default Sidebar | |