sidebar hover states

This commit is contained in:
marko-kraemer 2025-03-31 22:47:32 -07:00
parent c4cb7bc920
commit 296a68e8d1
4 changed files with 258 additions and 133 deletions

View File

@ -16,6 +16,9 @@ type ThreadParams = { id: string; threadId: string };
interface ApiMessage {
role: string;
content: string;
type?: 'content' | 'tool_call';
name?: string;
arguments?: string;
}
interface ApiAgentRun {
@ -71,14 +74,37 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
// Start streaming the agent's responses with improved implementation
const cleanup = streamAgent(runId, {
onMessage: (content: string) => {
// Skip empty content chunks
if (!content.trim()) return;
onMessage: (rawData: string) => {
try {
// Parse the outer data structure
const data = JSON.parse(rawData);
// Handle the nested data structure
if (data.content?.startsWith('data: ')) {
try {
const innerJson = data.content.replace('data: ', '');
const innerData = JSON.parse(innerJson);
// Skip empty messages
if (!innerData.content && !innerData.arguments) return;
// Improved stream update with requestAnimationFrame for smoother UI updates
window.requestAnimationFrame(() => {
setStreamContent(prev => prev + content);
if (innerData.type === 'tool_call') {
const toolContent = innerData.name
? `Tool: ${innerData.name}\n${innerData.arguments || ''}`
: innerData.arguments || '';
setStreamContent(prev => prev + (prev ? '\n' : '') + toolContent);
} else if (innerData.type === 'content' && innerData.content) {
setStreamContent(prev => prev + innerData.content);
}
});
} catch (innerError) {
console.warn('[PAGE] Failed to parse inner data:', innerError);
}
}
} catch (error) {
console.warn('[PAGE] Failed to parse message:', error);
}
},
onToolCall: (name: string, args: Record<string, unknown>) => {
console.log('[PAGE] Tool call received:', name, args);
@ -564,7 +590,16 @@ export default function ThreadPage({ params }: { params: Promise<ThreadParams> }
: 'bg-muted'
}`}
>
<div className="whitespace-pre-wrap break-words">{message.content}</div>
<div className="whitespace-pre-wrap break-words">
{message.type === 'tool_call' ? (
<div className="font-mono text-xs">
<div className="text-muted-foreground">Tool: {message.name}</div>
<div className="mt-1">{message.arguments}</div>
</div>
) : (
message.content
)}
</div>
</div>
</div>
))}

View File

@ -164,11 +164,55 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
}
}
const sidebarLogoMotion = {
tap: { scale: 0.97 },
hover: {
scale: 1.02,
transition: { type: "spring", stiffness: 400, damping: 17 }
}
}
const projectItemVariants = {
initial: { opacity: 0, y: -5 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -5 },
transition: { duration: 0.2 }
}
const threadListVariants = {
hidden: { opacity: 0, height: 0 },
visible: {
opacity: 1,
height: "auto",
transition: {
height: {
type: "spring",
stiffness: 500,
damping: 30
},
opacity: { duration: 0.2, delay: 0.05 }
}
},
exit: {
opacity: 0,
height: 0,
transition: {
height: { duration: 0.2 },
opacity: { duration: 0.1 }
}
}
}
return (
<Sidebar collapsible="offcanvas" {...props}>
<SidebarHeader className="border-b-0 h-14 px-4 py-3">
<Link href="/dashboard" className="flex items-center">
<div className="group/logo flex items-center">
<Link href="/dashboard" className="flex items-center group">
<motion.div
className="flex items-center"
whileHover="hover"
whileTap="tap"
variants={sidebarLogoMotion}
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
@ -177,12 +221,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="mr-2.5 h-5 w-5 text-black transition-transform duration-200 group-hover/logo:scale-110"
className="mr-2.5 h-5 w-5 text-black transition-colors group-hover:text-zinc-800"
>
<path d="M15 6v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3" />
</svg>
<span className="text-lg font-medium tracking-tight">AgentPress</span>
</div>
<span className="text-lg font-medium tracking-tight group-hover:text-zinc-800">AgentPress</span>
</motion.div>
</Link>
</SidebarHeader>
<SidebarContent className="py-0 px-2">
@ -191,18 +235,24 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<div className="flex items-center justify-between px-2 py-1.5">
<h3 className="text-xs uppercase tracking-wider text-zinc-500 font-medium">Projects</h3>
<TooltipProvider>
<Tooltip>
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<motion.div
whileHover={{ scale: 1.05 }}
whileTap={{ scale: 0.95 }}
className="cursor-pointer"
>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 rounded-md cursor-pointer transition-colors duration-200"
className="h-6 w-6 text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100 rounded-md cursor-pointer transition-all duration-150"
onClick={() => setIsDialogOpen(true)}
>
<IconPlus className="size-3.5" />
</Button>
</motion.div>
</TooltipTrigger>
<TooltipContent>
<TooltipContent side="right" className="text-xs">
<p>New Project</p>
</TooltipContent>
</Tooltip>
@ -223,33 +273,67 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</li>
) : (
projects.map((project) => (
<React.Fragment key={project.id}>
<motion.div
key={project.id}
initial="initial"
animate="animate"
exit="exit"
variants={projectItemVariants}
>
<li className="relative">
<div
className={`flex items-center justify-between px-2 py-1.5 text-sm rounded-md transition-all duration-200 ${
<motion.div
className={`flex items-center justify-between px-2 py-1.5 text-sm rounded-md transition-all duration-200 cursor-pointer ${
expandedProjectId === project.id
? 'text-zinc-900 font-medium bg-zinc-50'
? 'text-zinc-900 font-medium bg-zinc-50 hover:bg-zinc-100'
: 'text-zinc-700 hover:bg-zinc-50 hover:text-zinc-900'
}`}
whileHover={{
scale: 1.01,
x: 1
}}
transition={{ duration: 0.15 }}
style={{
backgroundColor: expandedProjectId === project.id ? 'rgb(249 250 251)' : 'transparent'
}}
initial={false}
animate={{
backgroundColor: expandedProjectId === project.id
? 'rgb(249 250 251)'
: 'transparent'
}}
whileTap={{ scale: 0.99 }}
onClick={(e) => {
// Either toggle expansion or navigate to project
if (expandedProjectId === project.id) {
router.push(`/projects/${project.id}`)
} else {
toggleProjectExpanded(project.id)
}
}}
role="button"
aria-expanded={expandedProjectId === project.id}
>
<Link
href={`/projects/${project.id}`}
className="flex-1 truncate transition-colors duration-200"
>
<div className="flex-1 truncate transition-colors duration-200">
{project.name}
</Link>
</div>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.9 }}
className="cursor-pointer"
>
<Button
variant="ghost"
size="icon"
className={`h-5 w-5 p-0 ml-1 transition-all duration-200 ${
className={`h-5 w-5 p-0 ml-1 rounded-full transition-all duration-200 cursor-pointer ${
expandedProjectId === project.id
? 'text-zinc-900 hover:bg-zinc-100'
: 'text-zinc-500 hover:text-zinc-900 hover:bg-zinc-100'
? 'text-zinc-900 hover:bg-zinc-200 hover:text-zinc-950'
: 'text-zinc-500 hover:text-zinc-900 hover:bg-zinc-200'
}`}
onClick={(e) => {
e.preventDefault()
e.stopPropagation() // Prevent parent's onClick from firing
toggleProjectExpanded(project.id)
}}
aria-label={expandedProjectId === project.id ? "Collapse project" : "Expand project"}
>
{expandedProjectId === project.id ? (
<IconChevronDown className="size-3.5" />
@ -257,38 +341,59 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<IconChevronRight className="size-3.5" />
)}
</Button>
</div>
</motion.div>
</motion.div>
</li>
<AnimatePresence>
{expandedProjectId === project.id && (
<motion.li
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
variants={threadListVariants}
initial="hidden"
animate="visible"
exit="exit"
className="overflow-hidden"
>
<ul className="pl-4 space-y-0.5">
<ul className="pl-4 space-y-0.5 mt-0.5">
{threads[project.id]?.map((thread) => (
<li key={thread.thread_id}>
<motion.li
key={thread.thread_id}
whileHover={{
x: 2,
}}
className={`rounded-md ${
pathname?.includes(`/threads/${thread.thread_id}`)
? 'bg-zinc-50 text-zinc-900 font-medium'
: 'text-zinc-700 hover:bg-zinc-50/70 hover:text-zinc-900'
}`}
transition={{
type: "spring",
stiffness: 400,
damping: 25
}}
>
<Link
href={`/projects/${project.id}/threads/${thread.thread_id}`}
className={`block px-2 py-1.5 text-sm rounded-md transition-all duration-200 ${
pathname?.includes(`/threads/${thread.thread_id}`)
? 'text-zinc-900 font-medium bg-zinc-50'
: 'text-zinc-700 hover:bg-zinc-50 hover:text-zinc-900'
}`}
className="block px-2 py-1.5 text-sm rounded-md transition-all duration-200 cursor-pointer w-full"
>
<span className="block truncate">
{thread.messages[0]?.content || 'New Conversation'}
{thread.messages[0]?.content && thread.messages[0].content.length > 30 ? '...' : ''}
</span>
</Link>
</li>
</motion.li>
))}
<li>
<motion.li
whileHover={{ x: 2 }}
transition={{ type: "spring", stiffness: 400, damping: 25 }}
className="rounded-md hover:bg-zinc-50/70"
>
<motion.div
whileTap={{ scale: 0.98 }}
className="w-full cursor-pointer"
>
<Button
variant="ghost"
size="sm"
className="w-full justify-start px-2 py-1.5 text-sm text-zinc-700 hover:text-zinc-900 hover:bg-zinc-50 transition-all duration-200"
className="w-full justify-start px-2 py-1.5 text-sm text-zinc-700 hover:text-zinc-900 transition-all duration-200 cursor-pointer rounded-md"
onClick={() => handleCreateThread(project.id)}
disabled={isCreatingThread[project.id]}
>
@ -304,12 +409,13 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</div>
)}
</Button>
</li>
</motion.div>
</motion.li>
</ul>
</motion.li>
)}
</AnimatePresence>
</React.Fragment>
</motion.div>
))
)}
</ul>
@ -317,11 +423,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
</SidebarContent>
<SidebarFooter className="border-t border-border/40">
{user && (
<motion.div
whileHover={{ y: -1 }}
transition={{ type: "spring", stiffness: 500 }}
className="cursor-pointer"
>
<NavUser user={{
name: user.email?.split('@')[0] || 'Guest',
email: user.email || '',
avatar: '/avatars/user.jpg',
}} />
</motion.div>
)}
</SidebarFooter>
<CreateProjectDialog

View File

@ -66,7 +66,7 @@ export function NavUser({
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="hover:bg-zinc-50 rounded-md transition-colors w-full py-1"
className="hover:bg-zinc-50 rounded-md transition-all duration-200 w-full py-1 group cursor-pointer active:bg-zinc-100"
>
<Avatar className="h-7 w-7 rounded-full mr-2">
<AvatarImage src={user.avatar} alt={user.name} />
@ -80,7 +80,9 @@ export function NavUser({
{user.email}
</span>
</div>
<IconDotsVertical className="ml-auto size-3.5 text-zinc-400" />
<div className="ml-auto p-0.5 rounded-full transition-all duration-200 group-hover:bg-zinc-100 group-active:bg-zinc-200">
<IconDotsVertical className="size-3.5 text-zinc-400 transition-all duration-200 group-hover:text-zinc-600 group-active:text-zinc-700" />
</div>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent

View File

@ -372,51 +372,27 @@ export const streamAgent = (agentRunId: string, callbacks: {
// Log raw data for debugging
console.log(`[STREAM] Received data: ${rawData.substring(0, 100)}${rawData.length > 100 ? '...' : ''}`);
const data = JSON.parse(rawData);
// Pass the raw data directly to onMessage for handling in the component
callbacks.onMessage(rawData);
if (data.type === 'content' && data.content) {
if (data.content.startsWith('data: {')) {
// Try to parse for tool calls
try {
const innerData = JSON.parse(data.content.substring(6));
if (innerData.type === 'content' && innerData.content) {
callbacks.onMessage(innerData.content);
} else if (innerData.type === 'tool_call') {
callbacks.onToolCall(innerData.name, innerData.arguments);
}
} catch {
callbacks.onMessage(data.content);
}
} else {
callbacks.onMessage(data.content);
}
} else if (data.type === 'tool_call') {
callbacks.onToolCall(data.name, data.arguments);
} else if (data.type === 'error') {
console.error(`[STREAM] Error from server: ${data.message}`);
callbacks.onError(data.message instanceof Error ? data.message : new Error(data.message));
} else if (data.type === 'status') {
console.log(`[STREAM] Status update: ${data.status}`);
const data = JSON.parse(rawData);
if (data.content?.startsWith('data: ')) {
const innerJson = data.content.replace('data: ', '');
const innerData = JSON.parse(innerJson);
if (data.status === 'completed') {
console.log(`[STREAM] Agent run completed - closing stream for ${agentRunId}`);
// Close connection first before handling completion
if (eventSourceInstance) {
console.log(`[STREAM] Closing EventSource for ${agentRunId}`);
eventSourceInstance.close();
eventSourceInstance = null;
if (innerData.type === 'tool_call') {
callbacks.onToolCall(innerData.name || '', innerData.arguments || '');
}
}
} catch (parseError) {
// Ignore parsing errors for tool calls
console.debug('[STREAM] Could not parse tool call data:', parseError);
}
// Then notify completion (once)
if (!isClosing) {
console.log(`[STREAM] Calling onClose for ${agentRunId}`);
isClosing = true;
callbacks.onClose();
}
}
}
} catch (error) {
console.error(`[STREAM] Error parsing message:`, error);
console.error(`[STREAM] Error handling message:`, error);
callbacks.onError(error instanceof Error ? error : String(error));
}
};