From a7b4c318817f1e20a287061f70003c3f746cb50b Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Wed, 14 Jan 2026 07:08:36 +1000 Subject: [PATCH] feat: improve agents and collections list page UX - Convert from table to card-based grid layout for better visual hierarchy - Add search functionality for both agents and collections - Add relative timestamps (e.g., "2h ago", "Yesterday") - Display provider names with colored text for agents - Add "Copy MCP URL" quick action for collections - Improve empty states with more descriptive messaging - Add loading skeletons that match new card design - Show collection MCP readiness status - Add help section explaining MCP integration --- apps/web/src/app/dashboard/agents/page.tsx | 445 ++++++++++------- .../src/app/dashboard/collections/page.tsx | 468 ++++++++++++------ 2 files changed, 601 insertions(+), 312 deletions(-) diff --git a/apps/web/src/app/dashboard/agents/page.tsx b/apps/web/src/app/dashboard/agents/page.tsx index d4dcf2a..dace4c3 100644 --- a/apps/web/src/app/dashboard/agents/page.tsx +++ b/apps/web/src/app/dashboard/agents/page.tsx @@ -1,21 +1,12 @@ 'use client'; import type { AIProvider } from '@tpmjs/types/agent'; -import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; -import { - Table, - TableBody, - TableCell, - TableEmpty, - TableHead, - TableHeader, - TableRow, -} from '@tpmjs/ui/Table/Table'; +import { Input } from '@tpmjs/ui/Input/Input'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; interface Agent { @@ -31,29 +22,166 @@ interface Agent { updatedAt: string; } -const PROVIDER_DISPLAY_NAMES: Record = { - OPENAI: 'OpenAI', - ANTHROPIC: 'Anthropic', - GOOGLE: 'Google', - GROQ: 'Groq', - MISTRAL: 'Mistral', +const PROVIDER_INFO: Record = { + OPENAI: { name: 'OpenAI', color: 'text-emerald-600 dark:text-emerald-400', bgColor: 'bg-emerald-500/10' }, + ANTHROPIC: { name: 'Anthropic', color: 'text-orange-600 dark:text-orange-400', bgColor: 'bg-orange-500/10' }, + GOOGLE: { name: 'Google', color: 'text-blue-600 dark:text-blue-400', bgColor: 'bg-blue-500/10' }, + GROQ: { name: 'Groq', color: 'text-purple-600 dark:text-purple-400', bgColor: 'bg-purple-500/10' }, + MISTRAL: { name: 'Mistral', color: 'text-red-600 dark:text-red-400', bgColor: 'bg-red-500/10' }, }; -const PROVIDER_COLORS: Record = { - OPENAI: 'default', - ANTHROPIC: 'secondary', - GOOGLE: 'outline', - GROQ: 'outline', - MISTRAL: 'outline', -}; - -function formatDate(dateString: string): string { +function formatRelativeDate(dateString: string): string { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + if (diffHours === 0) { + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + return diffMinutes <= 1 ? 'Just now' : `${diffMinutes}m ago`; + } + return `${diffHours}h ago`; + } + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function AgentCard({ + agent, + onDelete, + isDeleting, +}: { + agent: Agent; + onDelete: (id: string) => void; + isDeleting: boolean; +}) { + const router = useRouter(); + const providerInfo = PROVIDER_INFO[agent.provider]; + const totalTools = agent.toolCount + agent.collectionCount; + + return ( +
router.push(`/dashboard/agents/${agent.id}`)} + > + {/* Header */} +
+
+
+ +
+
+

+ {agent.name} +

+
+ + {providerInfo.name} + + + + {agent.modelId} + +
+
+
+ + {formatRelativeDate(agent.updatedAt)} + +
+ + {/* Description */} + {agent.description ? ( +

+ {agent.description} +

+ ) : ( +

+ No description +

+ )} + + {/* Stats */} +
+
+ + + {totalTools} tool{totalTools !== 1 ? 's' : ''} + +
+ {agent.collectionCount > 0 && ( +
+ + + {agent.collectionCount} collection{agent.collectionCount !== 1 ? 's' : ''} + +
+ )} +
+ + {/* Actions */} +
+ e.stopPropagation()} + className="flex-1" + > + + + e.stopPropagation()} + > + + + +
+
+ ); +} + +function AgentCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); } export default function AgentsPage(): React.ReactElement { @@ -62,6 +190,7 @@ export default function AgentsPage(): React.ReactElement { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [deletingId, setDeletingId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); const fetchAgents = useCallback(async () => { try { @@ -89,8 +218,19 @@ export default function AgentsPage(): React.ReactElement { fetchAgents(); }, [fetchAgents]); - const handleDelete = async (id: string, e: React.MouseEvent) => { - e.stopPropagation(); + const filteredAgents = useMemo(() => { + if (!searchQuery.trim()) return agents; + const query = searchQuery.toLowerCase(); + return agents.filter( + (agent) => + agent.name.toLowerCase().includes(query) || + agent.description?.toLowerCase().includes(query) || + agent.modelId.toLowerCase().includes(query) || + PROVIDER_INFO[agent.provider].name.toLowerCase().includes(query) + ); + }, [agents, searchQuery]); + + const handleDelete = async (id: string) => { if (!confirm('Are you sure you want to delete this agent? This action cannot be undone.')) { return; } @@ -130,10 +270,14 @@ export default function AgentsPage(): React.ReactElement { } >
- -

Error

-

{error}

- +
+ +
+

Something went wrong

+

{error}

+
); @@ -142,9 +286,7 @@ export default function AgentsPage(): React.ReactElement { return ( 0 ? `${agents.length} agent${agents.length !== 1 ? 's' : ''}` : undefined - } + subtitle="Create and manage AI agents with custom tools" actions={
+ + )} ); } diff --git a/apps/web/src/app/dashboard/collections/page.tsx b/apps/web/src/app/dashboard/collections/page.tsx index 60e5828..3727990 100644 --- a/apps/web/src/app/dashboard/collections/page.tsx +++ b/apps/web/src/app/dashboard/collections/page.tsx @@ -3,22 +3,16 @@ import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; -import { - Table, - TableBody, - TableCell, - TableEmpty, - TableHead, - TableHeader, - TableRow, -} from '@tpmjs/ui/Table/Table'; +import { Input } from '@tpmjs/ui/Input/Input'; +import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { CollectionForm } from '~/components/collections/CollectionForm'; import { DashboardLayout } from '~/components/dashboard/DashboardLayout'; interface Collection { id: string; + slug: string | null; name: string; description: string | null; toolCount: number; @@ -26,13 +20,172 @@ interface Collection { updatedAt: string; } -function formatDate(dateString: string): string { +function formatRelativeDate(dateString: string): string { const date = new Date(dateString); - return date.toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + + if (diffDays === 0) { + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + if (diffHours === 0) { + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + return diffMinutes <= 1 ? 'Just now' : `${diffMinutes}m ago`; + } + return `${diffHours}h ago`; + } + if (diffDays === 1) return 'Yesterday'; + if (diffDays < 7) return `${diffDays}d ago`; + if (diffDays < 30) return `${Math.floor(diffDays / 7)}w ago`; + return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); +} + +function CollectionCard({ + collection, + onDelete, + isDeleting, + username, +}: { + collection: Collection; + onDelete: (id: string) => void; + isDeleting: boolean; + username: string | null; +}) { + const router = useRouter(); + const [showCopied, setShowCopied] = useState(false); + + const mcpUrl = collection.slug && username + ? `${typeof window !== 'undefined' ? window.location.origin : ''}/api/mcp/${username}/${collection.slug}/http` + : null; + + const handleCopyMcpUrl = (e: React.MouseEvent) => { + e.stopPropagation(); + if (mcpUrl) { + navigator.clipboard.writeText(mcpUrl); + setShowCopied(true); + setTimeout(() => setShowCopied(false), 2000); + } + }; + + return ( +
router.push(`/dashboard/collections/${collection.id}`)} + > + {/* Header */} +
+
+
+ +
+
+

+ {collection.name} +

+
+ + {collection.isPublic ? 'Public' : 'Private'} + +
+
+
+ + {formatRelativeDate(collection.updatedAt)} + +
+ + {/* Description */} + {collection.description ? ( +

+ {collection.description} +

+ ) : ( +

+ No description +

+ )} + + {/* Stats */} +
+
+ + + {collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''} + +
+ {mcpUrl && ( +
+ + MCP Ready +
+ )} +
+ + {/* Actions */} +
+ {mcpUrl && ( + + )} + e.stopPropagation()} + className={mcpUrl ? '' : 'flex-1'} + > + + + +
+
+ ); +} + +function CollectionCardSkeleton() { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); } export default function CollectionsPage(): React.ReactElement { @@ -43,6 +196,8 @@ export default function CollectionsPage(): React.ReactElement { const [showCreateForm, setShowCreateForm] = useState(false); const [isCreating, setIsCreating] = useState(false); const [deletingId, setDeletingId] = useState(null); + const [searchQuery, setSearchQuery] = useState(''); + const [username, setUsername] = useState(null); const fetchCollections = useCallback(async () => { try { @@ -66,10 +221,32 @@ export default function CollectionsPage(): React.ReactElement { } }, [router]); + // Fetch user info for MCP URL + useEffect(() => { + fetch('/api/user/profile') + .then(res => res.json()) + .then(data => { + if (data.success && data.data?.username) { + setUsername(data.data.username); + } + }) + .catch(() => {}); + }, []); + useEffect(() => { fetchCollections(); }, [fetchCollections]); + const filteredCollections = useMemo(() => { + if (!searchQuery.trim()) return collections; + const query = searchQuery.toLowerCase(); + return collections.filter( + (collection) => + collection.name.toLowerCase().includes(query) || + collection.description?.toLowerCase().includes(query) + ); + }, [collections, searchQuery]); + const handleCreate = async (data: { name: string; description?: string; isPublic: boolean }) => { setIsCreating(true); @@ -96,8 +273,7 @@ export default function CollectionsPage(): React.ReactElement { } }; - const handleDelete = async (id: string, e: React.MouseEvent) => { - e.stopPropagation(); + const handleDelete = async (id: string) => { if ( !confirm('Are you sure you want to delete this collection? This action cannot be undone.') ) { @@ -138,10 +314,14 @@ export default function CollectionsPage(): React.ReactElement { } >
- -

Error

-

{error}

- +
+ +
+

Something went wrong

+

{error}

+
); @@ -150,11 +330,7 @@ export default function CollectionsPage(): React.ReactElement { return ( 0 - ? `${collections.length} collection${collections.length !== 1 ? 's' : ''}` - : undefined - } + subtitle="Organize tools into shareable MCP servers" actions={ !showCreateForm && (