feat: redesign public pages with virtualized tables

- Add sonner for toast notifications
- Create CopyButton component for simple copy actions
- Create CopyDropdown component with entity-specific copy options
- Create PackageManagerSelector with localStorage persistence
- Redesign tools page with TableVirtuoso, sort by downloads/likes/recent/name
- Redesign collections page with TableVirtuoso and infinite scroll
- Redesign agents page with TableVirtuoso and infinite scroll
- All tables have fast client-side filtering and copy functionality
This commit is contained in:
Ajax Davis 2026-01-07 21:55:13 +10:00
parent 54dedc3056
commit 361a49f3d7
10 changed files with 876 additions and 480 deletions

View file

@ -51,6 +51,7 @@
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"resend": "^6.6.0",
"sonner": "^2.0.7",
"zod": "^4.0.0"
},
"devDependencies": {

View file

@ -3,9 +3,14 @@
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input';
import { Select } from '@tpmjs/ui/Select/Select';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { AppHeader } from '~/components/AppHeader';
import { CopyDropdown, getAgentCopyOptions } from '~/components/CopyDropdown';
import { LikeButton } from '~/components/LikeButton';
interface PublicAgent {
@ -28,31 +33,52 @@ interface PublicAgent {
type SortOption = 'likes' | 'recent' | 'tools';
function sortAgents(agents: PublicAgent[], sortBy: SortOption): PublicAgent[] {
return [...agents].sort((a, b) => {
switch (sortBy) {
case 'likes':
return b.likeCount - a.likeCount;
case 'recent':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
case 'tools':
return b.toolCount - a.toolCount;
default:
return 0;
}
});
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trim()}...`;
}
export default function PublicAgentsPage(): React.ReactElement {
const [agents, setAgents] = useState<PublicAgent[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('likes');
const limit = 20;
const loadingMore = useRef(false);
const fetchAgents = useCallback(
async (currentOffset: number, resetList = false) => {
async (offset: number, resetList = false) => {
try {
if (loadingMore.current && !resetList) return;
loadingMore.current = true;
const params = new URLSearchParams({
limit: String(limit),
offset: String(currentOffset),
limit: '100',
offset: String(offset),
sort,
...(search && { search }),
});
const response = await fetch(`/api/public/agents?${params}`);
const data = await response.json();
if (data.success) {
if (resetList || currentOffset === 0) {
if (resetList || offset === 0) {
setAgents(data.data);
} else {
setAgents((prev) => [...prev, ...data.data]);
@ -66,29 +92,111 @@ export default function PublicAgentsPage(): React.ReactElement {
setError('Failed to fetch agents');
} finally {
setIsLoading(false);
loadingMore.current = false;
}
},
[sort, search]
[sort]
);
useEffect(() => {
setOffset(0);
setIsLoading(true);
fetchAgents(0, true);
}, [fetchAgents]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchAgents(newOffset);
};
const loadMore = useCallback(() => {
if (!hasMore || loadingMore.current) return;
fetchAgents(agents.length);
}, [hasMore, agents.length, fetchAgents]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
setIsLoading(true);
fetchAgents(0, true);
};
// Filter and sort agents
const filteredAgents = useMemo(() => {
let result = agents;
if (search) {
const query = search.toLowerCase();
result = result.filter(
(a) =>
a.name.toLowerCase().includes(query) ||
a.description?.toLowerCase().includes(query) ||
a.provider.toLowerCase().includes(query)
);
}
return sortAgents(result, sort);
}, [agents, search, sort]);
const TableHeader = useCallback(
() => (
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
<th className="px-4 py-3 w-[200px]">Name</th>
<th className="px-4 py-3 w-[250px]">Description</th>
<th className="px-4 py-3 w-[100px]">Provider</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
</tr>
),
[]
);
const TableRow = useCallback((_index: number, agent: PublicAgent) => {
return (
<>
<td className="px-4 py-3">
<Link
href={`/agents/${agent.id}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{agent.name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-foreground-secondary">
{agent.description ? truncateText(agent.description, 50) : '—'}
</td>
<td className="px-4 py-3">
<Badge variant="secondary" size="sm">
{agent.provider}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<Badge variant="secondary" size="sm">
{agent.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="agent"
entityId={agent.id}
initialCount={agent.likeCount}
size="sm"
showCount={true}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{agent.createdBy.image ? (
<img
src={agent.createdBy.image}
alt={agent.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
{agent.createdBy.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-right">
<CopyDropdown options={getAgentCopyOptions(agent.uid, agent.name)} buttonLabel="Copy" />
</td>
</>
);
}, []);
return (
<div className="min-h-screen bg-background">
@ -105,34 +213,26 @@ export default function PublicAgentsPage(): React.ReactElement {
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search agents..."
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</form>
<div className="flex-1">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search agents..."
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Sort:</span>
<select
<Select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="likes">Most Liked</option>
<option value="recent">Most Recent</option>
<option value="tools">Most Tools</option>
</select>
options={[
{ value: 'likes', label: 'Most Liked' },
{ value: 'recent', label: 'Most Recent' },
{ value: 'tools', label: 'Most Tools' },
]}
/>
</div>
</div>
@ -145,20 +245,11 @@ export default function PublicAgentsPage(): React.ReactElement {
<Button onClick={() => fetchAgents(0, true)}>Try Again</Button>
</div>
) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-6 animate-pulse"
>
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-1/3" />
</div>
))}
<div className="flex items-center justify-center py-24 gap-4">
<Spinner size="lg" />
<span className="text-foreground-secondary font-mono text-sm">Loading agents...</span>
</div>
) : agents.length === 0 ? (
) : filteredAgents.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="terminal" size="lg" className="text-primary" />
@ -170,75 +261,42 @@ export default function PublicAgentsPage(): React.ReactElement {
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div
key={agent.id}
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<Link
href={`/agents/${agent.id}`}
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
>
{agent.name}
</Link>
<LikeButton
entityType="agent"
entityId={agent.id}
initialCount={agent.likeCount}
size="sm"
<div className="border border-border rounded-lg overflow-hidden">
<TableVirtuoso
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
data={filteredAgents}
overscan={30}
endReached={loadMore}
fixedHeaderContent={TableHeader}
itemContent={TableRow}
components={{
Table: (props) => (
<table
{...props}
className="w-full border-collapse text-sm"
style={{ tableLayout: 'fixed' }}
/>
</div>
{agent.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-3">
{agent.description}
</p>
)}
<div className="flex items-center gap-2 mb-3">
<Badge variant="secondary" size="sm">
{agent.provider}
</Badge>
<span className="text-xs text-foreground-tertiary">{agent.modelId}</span>
</div>
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3 text-foreground-tertiary">
<span className="flex items-center gap-1">
<Icon icon="puzzle" size="xs" />
{agent.toolCount} tool{agent.toolCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
{agent.createdBy.image ? (
<img
src={agent.createdBy.image}
alt={agent.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-xs text-foreground-tertiary">
{agent.createdBy.name}
</span>
</div>
</div>
</div>
))}
),
TableHead: (props) => (
<thead {...props} className="bg-surface sticky top-0 z-10" />
),
TableBody: (props) => <tbody {...props} />,
TableRow: (props) => (
<tr
{...props}
className="border-b border-border hover:bg-surface/50 transition-colors"
/>
),
}}
/>
</div>
{hasMore && (
<div className="mt-8 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
<div className="mt-4 text-sm text-foreground-tertiary">
Showing {filteredAgents.length} agent
{filteredAgents.length !== 1 ? 's' : ''}
{search && ` matching "${search}"`}
{hasMore && ' (scroll for more)'}
</div>
</>
)}
</main>

View file

@ -1,10 +1,16 @@
'use client';
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input';
import { Select } from '@tpmjs/ui/Select/Select';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import Link from 'next/link';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { AppHeader } from '~/components/AppHeader';
import { CopyDropdown, getCollectionCopyOptions } from '~/components/CopyDropdown';
import { LikeButton } from '~/components/LikeButton';
interface PublicCollection {
@ -23,31 +29,52 @@ interface PublicCollection {
type SortOption = 'likes' | 'recent' | 'tools';
function sortCollections(collections: PublicCollection[], sortBy: SortOption): PublicCollection[] {
return [...collections].sort((a, b) => {
switch (sortBy) {
case 'likes':
return b.likeCount - a.likeCount;
case 'recent':
return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
case 'tools':
return b.toolCount - a.toolCount;
default:
return 0;
}
});
}
function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return `${text.slice(0, maxLength).trim()}...`;
}
export default function PublicCollectionsPage(): React.ReactElement {
const [collections, setCollections] = useState<PublicCollection[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [hasMore, setHasMore] = useState(false);
const [offset, setOffset] = useState(0);
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('likes');
const limit = 20;
const loadingMore = useRef(false);
const fetchCollections = useCallback(
async (currentOffset: number, resetList = false) => {
async (offset: number, resetList = false) => {
try {
if (loadingMore.current && !resetList) return;
loadingMore.current = true;
const params = new URLSearchParams({
limit: String(limit),
offset: String(currentOffset),
limit: '100',
offset: String(offset),
sort,
...(search && { search }),
});
const response = await fetch(`/api/public/collections?${params}`);
const data = await response.json();
if (data.success) {
if (resetList || currentOffset === 0) {
if (resetList || offset === 0) {
setCollections(data.data);
} else {
setCollections((prev) => [...prev, ...data.data]);
@ -61,29 +88,105 @@ export default function PublicCollectionsPage(): React.ReactElement {
setError('Failed to fetch collections');
} finally {
setIsLoading(false);
loadingMore.current = false;
}
},
[sort, search]
[sort]
);
useEffect(() => {
setOffset(0);
setIsLoading(true);
fetchCollections(0, true);
}, [fetchCollections]);
const loadMore = () => {
const newOffset = offset + limit;
setOffset(newOffset);
fetchCollections(newOffset);
};
const loadMore = useCallback(() => {
if (!hasMore || loadingMore.current) return;
fetchCollections(collections.length);
}, [hasMore, collections.length, fetchCollections]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setOffset(0);
setIsLoading(true);
fetchCollections(0, true);
};
// Filter and sort collections
const filteredCollections = useMemo(() => {
let result = collections;
if (search) {
const query = search.toLowerCase();
result = result.filter(
(c) => c.name.toLowerCase().includes(query) || c.description?.toLowerCase().includes(query)
);
}
return sortCollections(result, sort);
}, [collections, search, sort]);
const TableHeader = useCallback(
() => (
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
<th className="px-4 py-3 w-[250px]">Name</th>
<th className="px-4 py-3 w-[300px]">Description</th>
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
<th className="px-4 py-3 w-[150px]">Creator</th>
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
</tr>
),
[]
);
const TableRow = useCallback((_index: number, collection: PublicCollection) => {
return (
<>
<td className="px-4 py-3">
<Link
href={`/collections/${collection.id}`}
className="font-medium text-foreground hover:text-primary transition-colors"
>
{collection.name}
</Link>
</td>
<td className="px-4 py-3 text-sm text-foreground-secondary">
{collection.description ? truncateText(collection.description, 60) : '—'}
</td>
<td className="px-4 py-3 text-center">
<Badge variant="secondary" size="sm">
{collection.toolCount}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<LikeButton
entityType="collection"
entityId={collection.id}
initialCount={collection.likeCount}
size="sm"
showCount={true}
/>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{collection.createdBy.image ? (
<img
src={collection.createdBy.image}
alt={collection.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-sm text-foreground-secondary truncate max-w-[100px]">
{collection.createdBy.name}
</span>
</div>
</td>
<td className="px-4 py-3 text-right">
<CopyDropdown
options={getCollectionCopyOptions(collection.id, collection.name)}
buttonLabel="Copy"
/>
</td>
</>
);
}, []);
return (
<div className="min-h-screen bg-background">
@ -100,34 +203,26 @@ export default function PublicCollectionsPage(): React.ReactElement {
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<form onSubmit={handleSearch} className="flex-1">
<div className="relative">
<Icon
icon="search"
size="sm"
className="absolute left-3 top-1/2 -translate-y-1/2 text-foreground-tertiary"
/>
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search collections..."
className="w-full pl-10 pr-4 py-2 bg-surface border border-border rounded-lg text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50"
/>
</div>
</form>
<div className="flex-1">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search collections..."
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Sort:</span>
<select
<Select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
className="px-3 py-2 bg-surface border border-border rounded-lg text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-primary/50"
>
<option value="likes">Most Liked</option>
<option value="recent">Most Recent</option>
<option value="tools">Most Tools</option>
</select>
options={[
{ value: 'likes', label: 'Most Liked' },
{ value: 'recent', label: 'Most Recent' },
{ value: 'tools', label: 'Most Tools' },
]}
/>
</div>
</div>
@ -140,20 +235,13 @@ export default function PublicCollectionsPage(): React.ReactElement {
<Button onClick={() => fetchCollections(0, true)}>Try Again</Button>
</div>
) : isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="bg-background border border-border rounded-lg p-6 animate-pulse"
>
<div className="h-6 bg-surface-secondary rounded w-3/4 mb-3" />
<div className="h-4 bg-surface-secondary rounded w-full mb-2" />
<div className="h-4 bg-surface-secondary rounded w-2/3 mb-4" />
<div className="h-4 bg-surface-secondary rounded w-1/3" />
</div>
))}
<div className="flex items-center justify-center py-24 gap-4">
<Spinner size="lg" />
<span className="text-foreground-secondary font-mono text-sm">
Loading collections...
</span>
</div>
) : collections.length === 0 ? (
) : filteredCollections.length === 0 ? (
<div className="text-center py-16">
<div className="w-16 h-16 rounded-full bg-primary/10 flex items-center justify-center mx-auto mb-4">
<Icon icon="folder" size="lg" className="text-primary" />
@ -167,68 +255,42 @@ export default function PublicCollectionsPage(): React.ReactElement {
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{collections.map((collection) => (
<div
key={collection.id}
className="bg-background border border-border rounded-lg p-6 hover:border-foreground/20 transition-colors"
>
<div className="flex items-start justify-between mb-3">
<Link
href={`/collections/${collection.id}`}
className="text-lg font-medium text-foreground hover:text-primary transition-colors"
>
{collection.name}
</Link>
<LikeButton
entityType="collection"
entityId={collection.id}
initialCount={collection.likeCount}
size="sm"
<div className="border border-border rounded-lg overflow-hidden">
<TableVirtuoso
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
data={filteredCollections}
overscan={30}
endReached={loadMore}
fixedHeaderContent={TableHeader}
itemContent={TableRow}
components={{
Table: (props) => (
<table
{...props}
className="w-full border-collapse text-sm"
style={{ tableLayout: 'fixed' }}
/>
</div>
{collection.description && (
<p className="text-sm text-foreground-secondary line-clamp-2 mb-4">
{collection.description}
</p>
)}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-3 text-foreground-tertiary">
<span className="flex items-center gap-1">
<Icon icon="puzzle" size="xs" />
{collection.toolCount} tool{collection.toolCount !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-2">
{collection.createdBy.image ? (
<img
src={collection.createdBy.image}
alt={collection.createdBy.name}
className="w-5 h-5 rounded-full"
/>
) : (
<div className="w-5 h-5 rounded-full bg-primary/10 flex items-center justify-center">
<Icon icon="user" size="xs" className="text-primary" />
</div>
)}
<span className="text-xs text-foreground-tertiary">
{collection.createdBy.name}
</span>
</div>
</div>
</div>
))}
),
TableHead: (props) => (
<thead {...props} className="bg-surface sticky top-0 z-10" />
),
TableBody: (props) => <tbody {...props} />,
TableRow: (props) => (
<tr
{...props}
className="border-b border-border hover:bg-surface/50 transition-colors"
/>
),
}}
/>
</div>
{hasMore && (
<div className="mt-8 text-center">
<Button variant="outline" onClick={loadMore}>
Load More
</Button>
</div>
)}
<div className="mt-4 text-sm text-foreground-tertiary">
Showing {filteredCollections.length} collection
{filteredCollections.length !== 1 ? 's' : ''}
{search && ` matching "${search}"`}
{hasMore && ' (scroll for more)'}
</div>
</>
)}
</main>

View file

@ -2,6 +2,7 @@ import { Analytics } from '@vercel/analytics/next';
import type { Metadata } from 'next';
import { Space_Grotesk, Space_Mono } from 'next/font/google';
import Script from 'next/script';
import { Toaster } from 'sonner';
import { AppFooter } from '../components/AppFooter';
import { ThemeProvider } from '../components/providers/ThemeProvider';
import './globals.css';
@ -160,6 +161,7 @@ export default function RootLayout({
<div className="flex-1">{children}</div>
<AppFooter />
</div>
<Toaster position="bottom-right" richColors closeButton />
</ThemeProvider>
<Analytics />
</body>

View file

@ -2,24 +2,29 @@
import { Badge } from '@tpmjs/ui/Badge/Badge';
import { Button } from '@tpmjs/ui/Button/Button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
import { Container } from '@tpmjs/ui/Container/Container';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { Input } from '@tpmjs/ui/Input/Input';
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
import { Select } from '@tpmjs/ui/Select/Select';
import { Spinner } from '@tpmjs/ui/Spinner/Spinner';
import { formatTimeAgo } from '@tpmjs/utils/format';
import Link from 'next/link';
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { TableVirtuoso } from 'react-virtuoso';
import { AppHeader } from '~/components/AppHeader';
import { CopyButton } from '~/components/CopyButton';
import {
PackageManagerSelector,
getInstallCommand,
usePackageManager,
} from '~/components/PackageManagerSelector';
interface Tool {
id: string;
name: string;
description: string;
qualityScore: string;
likeCount?: number;
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
createdAt: string;
@ -34,7 +39,13 @@ interface Tool {
};
}
type SortOption = 'downloads' | 'recent';
type SortOption = 'downloads' | 'likes' | 'recent' | 'name';
function formatDownloads(count: number): string {
if (count >= 1000000) return `${(count / 1000000).toFixed(1)}M`;
if (count >= 1000) return `${(count / 1000).toFixed(1)}k`;
return count.toString();
}
/** Sort tools by criterion, pushing broken tools to the bottom */
function sortTools(tools: Tool[], sortBy: SortOption): Tool[] {
@ -44,24 +55,35 @@ function sortTools(tools: Tool[], sortBy: SortOption): Tool[] {
// Always push broken tools to bottom
if (aIsBroken && !bIsBroken) return 1;
if (!aIsBroken && bIsBroken) return -1;
// Within same broken status, sort by selected criterion
if (sortBy === 'downloads') {
const aDownloads = a.package.npmDownloadsLastMonth ?? 0;
const bDownloads = b.package.npmDownloadsLastMonth ?? 0;
return bDownloads - aDownloads;
switch (sortBy) {
case 'downloads': {
const aDownloads = a.package.npmDownloadsLastMonth ?? 0;
const bDownloads = b.package.npmDownloadsLastMonth ?? 0;
return bDownloads - aDownloads;
}
case 'likes': {
const aLikes = a.likeCount ?? 0;
const bLikes = b.likeCount ?? 0;
return bLikes - aLikes;
}
case 'recent': {
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return bTime - aTime;
}
case 'name': {
const aName = a.name.toLowerCase();
const bName = b.name.toLowerCase();
return aName.localeCompare(bName);
}
default:
return 0;
}
// Sort by recent (createdAt descending)
const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0;
return bTime - aTime;
});
}
/**
* Tool Registry Search Page
*
* Fetches tools from the /api/tools endpoint and displays them in a searchable grid.
*/
export default function ToolSearchPage(): React.ReactElement {
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
@ -71,19 +93,15 @@ export default function ToolSearchPage(): React.ReactElement {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
const [packageManager, setPackageManager] = usePackageManager();
// Fetch tools from API
useEffect(() => {
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tool search page requires complex filtering logic
const fetchTools = async () => {
try {
setLoading(true);
const params = new URLSearchParams();
if (searchQuery) {
params.set('q', searchQuery);
}
if (categoryFilter !== 'all') {
params.set('category', categoryFilter);
}
@ -102,18 +120,16 @@ export default function ToolSearchPage(): React.ReactElement {
if (toolsData.success) {
const fetchedTools = toolsData.data;
setTools(sortTools(fetchedTools, sortBy));
setTools(fetchedTools);
setError(null);
// Extract unique categories from all tools
const categories = new Set<string>();
for (const tool of fetchedTools) {
if (tool.package.category) {
categories.add(tool.package.category);
}
}
setAvailableCategories(Array.from(categories).sort());
} else {
setError(toolsData.error || 'Failed to fetch tools');
@ -126,13 +142,93 @@ export default function ToolSearchPage(): React.ReactElement {
};
fetchTools();
}, [searchQuery, categoryFilter, healthFilter, sortBy]);
}, [categoryFilter, healthFilter]);
// Filter and sort tools
const filteredTools = useMemo(() => {
let result = tools;
// Apply search filter
if (searchQuery) {
const query = searchQuery.toLowerCase();
result = result.filter(
(tool) =>
tool.name.toLowerCase().includes(query) ||
tool.package.npmPackageName.toLowerCase().includes(query) ||
tool.description.toLowerCase().includes(query)
);
}
// Sort tools
return sortTools(result, sortBy);
}, [tools, searchQuery, sortBy]);
const TableHeader = useCallback(
() => (
<tr className="bg-surface text-left text-sm font-medium text-foreground-secondary">
<th className="px-4 py-3 w-[300px]">Name</th>
<th className="px-4 py-3 w-[120px]">Category</th>
<th className="px-4 py-3 w-[100px] text-right">Downloads</th>
<th className="px-4 py-3 w-[80px] text-right">Likes</th>
<th className="px-4 py-3 w-[120px] text-right">Install</th>
</tr>
),
[]
);
const TableRow = useCallback(
(_index: number, tool: Tool) => {
const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
const displayName = tool.name !== 'default' ? tool.name : tool.package.npmPackageName;
const installCommand = getInstallCommand(tool.package.npmPackageName, packageManager);
return (
<>
<td className="px-4 py-3">
<Link
href={`/tool/${tool.package.npmPackageName}/${tool.name}`}
className="group block"
>
<div className="font-medium text-foreground group-hover:text-primary transition-colors">
{displayName}
{isBroken && (
<Badge variant="error" size="sm" className="ml-2">
Broken
</Badge>
)}
</div>
<div className="text-xs text-foreground-tertiary truncate max-w-[280px]">
{tool.package.npmPackageName}
</div>
</Link>
</td>
<td className="px-4 py-3">
<Badge variant="secondary" size="sm">
{tool.package.category}
</Badge>
</td>
<td className="px-4 py-3 text-right text-sm text-foreground-secondary tabular-nums">
{formatDownloads(tool.package.npmDownloadsLastMonth)}
</td>
<td className="px-4 py-3 text-right">
<span className="inline-flex items-center gap-1 text-sm text-foreground-secondary">
<Icon icon="heart" size="xs" />
{tool.likeCount ?? 0}
</span>
</td>
<td className="px-4 py-3 text-right">
<CopyButton text={installCommand} label="Copy" size="xs" />
</td>
</>
);
},
[packageManager]
);
return (
<div className="min-h-screen bg-background">
<AppHeader />
{/* Main content */}
<Container size="xl" padding="md" className="py-8">
{/* Page header */}
<div className="space-y-4 mb-8">
@ -155,9 +251,9 @@ export default function ToolSearchPage(): React.ReactElement {
/>
{/* Filter row */}
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4">
<div className="flex flex-col sm:flex-row flex-wrap gap-3 sm:gap-4 items-start sm:items-center">
{/* Category filter */}
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
Category:
</span>
@ -165,7 +261,7 @@ export default function ToolSearchPage(): React.ReactElement {
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
size="sm"
className="flex-1 sm:flex-none sm:min-w-[150px]"
className="min-w-[150px]"
options={[
{ value: 'all', label: 'All Categories' },
...availableCategories.map((cat) => ({
@ -177,7 +273,7 @@ export default function ToolSearchPage(): React.ReactElement {
</div>
{/* Health filter */}
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
Health:
</span>
@ -185,7 +281,7 @@ export default function ToolSearchPage(): React.ReactElement {
value={healthFilter}
onChange={(e) => setHealthFilter(e.target.value)}
size="sm"
className="flex-1 sm:flex-none sm:min-w-[130px]"
className="min-w-[130px]"
options={[
{ value: 'all', label: 'All Tools' },
{ value: 'healthy', label: 'Healthy Only' },
@ -195,7 +291,7 @@ export default function ToolSearchPage(): React.ReactElement {
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 w-full sm:w-auto">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-foreground-secondary whitespace-nowrap">
Sort:
</span>
@ -203,10 +299,12 @@ export default function ToolSearchPage(): React.ReactElement {
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
size="sm"
className="flex-1 sm:flex-none sm:min-w-[150px]"
className="min-w-[150px]"
options={[
{ value: 'downloads', label: 'Most Downloaded' },
{ value: 'downloads', label: 'Downloads' },
{ value: 'likes', label: 'Likes' },
{ value: 'recent', label: 'Recent' },
{ value: 'name', label: 'Name (A-Z)' },
]}
/>
</div>
@ -225,6 +323,9 @@ export default function ToolSearchPage(): React.ReactElement {
</Button>
)}
</div>
{/* Package manager selector */}
<PackageManagerSelector value={packageManager} onChange={setPackageManager} />
</div>
{/* Loading state */}
@ -240,141 +341,55 @@ export default function ToolSearchPage(): React.ReactElement {
{/* Error state */}
{error && <div className="text-center py-12 text-red-500">Error: {error}</div>}
{/* Tool grid */}
{!loading && !error && tools.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6">
{tools.map((tool) => {
const isBroken = tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN';
const qualityPercent = Math.round(Number.parseFloat(tool.qualityScore) * 100);
{/* Tool table */}
{!loading && !error && filteredTools.length > 0 && (
<div className="border border-border rounded-lg overflow-hidden">
<TableVirtuoso
style={{ height: 'calc(100vh - 400px)', minHeight: '400px' }}
data={filteredTools}
overscan={50}
fixedHeaderContent={TableHeader}
itemContent={TableRow}
components={{
Table: (props) => (
<table
{...props}
className="w-full border-collapse text-sm"
style={{ tableLayout: 'fixed' }}
/>
),
TableHead: (props) => <thead {...props} className="bg-surface sticky top-0 z-10" />,
TableBody: (props) => <tbody {...props} />,
TableRow: (props) => (
<tr
{...props}
className="border-b border-border hover:bg-surface/50 transition-colors"
/>
),
}}
/>
</div>
)}
// Clean up repository URL
let repoUrl = tool.package.npmRepository?.url || '';
repoUrl = repoUrl.replace(/^git\+/, '');
repoUrl = repoUrl.replace(/\.git$/, '');
repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
return (
<Link
key={tool.id}
href={`/tool/${tool.package.npmPackageName}/${tool.name}`}
className="block select-text"
>
<Card className="flex flex-col h-full hover:border-foreground-tertiary transition-colors cursor-pointer select-text">
<CardHeader className="flex-none">
{/* Top row: Title + metadata */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<CardTitle className="truncate">
{tool.name !== 'default' ? tool.name : tool.package.npmPackageName}
</CardTitle>
<div className="text-sm text-foreground-secondary mt-1 truncate">
{tool.package.npmPackageName}
</div>
</div>
{/* Right side: downloads, version, link */}
<div className="flex items-center gap-2 flex-shrink-0 text-xs text-foreground-tertiary">
<span>{tool.package.npmDownloadsLastMonth.toLocaleString()}/mo</span>
<span>v{tool.package.npmVersion}</span>
{repoUrl && (
<button
type="button"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(repoUrl, '_blank', 'noopener,noreferrer');
}}
className="text-foreground-secondary hover:text-foreground transition-colors cursor-pointer"
>
<Icon icon="externalLink" size="sm" />
</button>
)}
</div>
</div>
{/* Description */}
<CardDescription className="line-clamp-2 min-h-[2.5rem]">
{tool.description}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col gap-4">
{/* Category badge */}
<div className="flex items-center">
<Badge variant="secondary" size="sm">
{tool.package.category}
</Badge>
</div>
{/* Quality + Broken status row */}
<div className="flex items-center gap-3">
<div className="flex-1 flex items-center gap-2">
<ProgressBar
value={qualityPercent}
variant={
isBroken
? 'danger'
: qualityPercent >= 70
? 'success'
: qualityPercent >= 50
? 'primary'
: 'warning'
}
size="sm"
showLabel={false}
className="flex-1"
/>
<span className="text-xs font-medium text-foreground-secondary w-8">
{qualityPercent}%
</span>
</div>
{isBroken && (
<Badge variant="error" size="sm">
Broken
</Badge>
)}
</div>
{/* Bottom section with install command and published date */}
<div className="mt-auto space-y-2">
<div
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
role="presentation"
>
<CodeBlock
code={`npm install ${tool.package.npmPackageName}`}
language="bash"
size="sm"
showCopy={true}
/>
</div>
{tool.package.npmPublishedAt && (
<div className="text-xs text-foreground-tertiary">
Published {formatTimeAgo(tool.package.npmPublishedAt)}
</div>
)}
</div>
</CardContent>
</Card>
</Link>
);
})}
{/* Results count */}
{!loading && !error && filteredTools.length > 0 && (
<div className="mt-4 text-sm text-foreground-tertiary">
Showing {filteredTools.length} tool{filteredTools.length !== 1 ? 's' : ''}
{searchQuery && ` matching "${searchQuery}"`}
</div>
)}
{/* Empty States */}
{!loading && !error && tools.length === 0 && (
{!loading && !error && filteredTools.length === 0 && (
<div className="flex items-center justify-center py-24">
<Card className="max-w-2xl w-full">
<CardContent className="pt-6 pb-6 text-center space-y-6">
{/* Icon/Visual Element */}
<div className="flex justify-center">
<div className="w-16 h-16 rounded-full bg-muted flex items-center justify-center">
<Icon icon="x" size="lg" className="text-foreground-tertiary" />
</div>
</div>
{/* Search query with no results */}
{searchQuery && (
<>
<div className="space-y-2">
@ -405,7 +420,6 @@ export default function ToolSearchPage(): React.ReactElement {
</>
)}
{/* Filters active but no search query */}
{!searchQuery && (categoryFilter !== 'all' || healthFilter !== 'all') && (
<>
<div className="space-y-2">
@ -415,32 +429,19 @@ export default function ToolSearchPage(): React.ReactElement {
<p className="text-foreground-secondary">
Try adjusting or clearing your filters to see more tools.
</p>
{categoryFilter !== 'all' && (
<p className="text-sm text-foreground-tertiary">
Current filter: Category = {categoryFilter}
</p>
)}
{healthFilter !== 'all' && (
<p className="text-sm text-foreground-tertiary">
Current filter: Health = {healthFilter}
</p>
)}
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="default"
onClick={() => {
setCategoryFilter('all');
setHealthFilter('all');
}}
>
Clear All Filters
</Button>
</div>
<Button
variant="default"
onClick={() => {
setCategoryFilter('all');
setHealthFilter('all');
}}
>
Clear All Filters
</Button>
</>
)}
{/* No tools at all (edge case) */}
{!searchQuery && categoryFilter === 'all' && healthFilter === 'all' && (
<>
<div className="space-y-2">
@ -449,64 +450,15 @@ export default function ToolSearchPage(): React.ReactElement {
Be the first to publish a tool and help AI agents gain new capabilities.
</p>
</div>
<div className="flex flex-col sm:flex-row gap-3 justify-center">
<Button
variant="default"
onClick={() =>
window.open('https://github.com/tpmjs/tpmjs', '_blank', 'noopener')
}
>
<Icon icon="github" size="sm" className="mr-2" />
View Documentation
</Button>
<Button
variant="outline"
onClick={() =>
window.open(
'https://www.npmjs.com/search?q=keywords:tpmjs',
'_blank',
'noopener'
)
}
>
Browse on npm
</Button>
</div>
<div className="pt-4 border-t border-border mt-6">
<p className="text-sm text-foreground-tertiary mb-4">
Publishing a tool is easy:
</p>
<div className="space-y-3 text-left max-w-md mx-auto">
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
1
</div>
<p className="text-sm text-foreground-secondary">
Add{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs">tpmjs</code>{' '}
keyword to your package.json
</p>
</div>
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
2
</div>
<p className="text-sm text-foreground-secondary">
Include a{' '}
<code className="px-1.5 py-0.5 bg-muted rounded text-xs">tpmjs</code>{' '}
field with tool metadata
</p>
</div>
<div className="flex gap-3">
<div className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary flex items-center justify-center text-xs font-bold">
3
</div>
<p className="text-sm text-foreground-secondary">
Publish to npm and your tool appears here automatically
</p>
</div>
</div>
</div>
<Button
variant="default"
onClick={() =>
window.open('https://github.com/tpmjs/tpmjs', '_blank', 'noopener')
}
>
<Icon icon="github" size="sm" className="mr-2" />
View Documentation
</Button>
</>
)}
</CardContent>

View file

@ -0,0 +1,48 @@
'use client';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { useCallback, useState } from 'react';
import { toast } from 'sonner';
interface CopyButtonProps {
text: string;
label?: string;
successMessage?: string;
size?: 'xs' | 'sm' | 'md';
className?: string;
}
export function CopyButton({
text,
label,
successMessage = 'Copied to clipboard',
size = 'sm',
className = '',
}: CopyButtonProps): React.ReactElement {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
toast.success(successMessage);
setTimeout(() => setCopied(false), 2000);
} catch {
toast.error('Failed to copy');
}
}, [text, successMessage]);
const iconSize = size === 'xs' ? 'xs' : size === 'sm' ? 'sm' : 'md';
return (
<button
type="button"
onClick={handleCopy}
className={`inline-flex items-center gap-1.5 px-2 py-1 text-foreground-secondary hover:text-foreground hover:bg-surface rounded transition-colors ${className}`}
title={label || 'Copy to clipboard'}
>
<Icon icon={copied ? 'check' : 'copy'} size={iconSize} />
{label && <span className="text-xs">{label}</span>}
</button>
);
}

View file

@ -0,0 +1,146 @@
'use client';
import { Icon } from '@tpmjs/ui/Icon/Icon';
import { useCallback, useEffect, useRef, useState } from 'react';
import { toast } from 'sonner';
interface CopyOption {
label: string;
value: string;
description?: string;
}
interface CopyDropdownProps {
options: CopyOption[];
buttonLabel?: string;
className?: string;
}
export function CopyDropdown({
options,
buttonLabel = 'Copy',
className = '',
}: CopyDropdownProps): React.ReactElement {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleCopy = useCallback(async (option: CopyOption) => {
try {
await navigator.clipboard.writeText(option.value);
toast.success(`${option.label} copied to clipboard`);
setIsOpen(false);
} catch {
toast.error('Failed to copy');
}
}, []);
return (
<div className={`relative ${className}`} ref={dropdownRef}>
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className="inline-flex items-center gap-1 px-2 py-1 text-xs text-foreground-secondary hover:text-foreground hover:bg-surface rounded transition-colors"
>
<Icon icon="copy" size="xs" />
<span>{buttonLabel}</span>
<Icon
icon="chevronDown"
size="xs"
className={`transition-transform ${isOpen ? 'rotate-180' : ''}`}
/>
</button>
{isOpen && (
<div className="absolute right-0 top-full mt-1 w-56 bg-background border border-border rounded-lg shadow-lg py-1 z-50">
{options.map((option) => (
<button
key={option.label}
type="button"
onClick={() => handleCopy(option)}
className="w-full px-3 py-2 text-left text-sm hover:bg-surface transition-colors"
>
<div className="font-medium text-foreground">{option.label}</div>
{option.description && (
<div className="text-xs text-foreground-tertiary truncate">
{option.description}
</div>
)}
</button>
))}
</div>
)}
</div>
);
}
// Helper functions to generate copy options for different entity types
export function getCollectionCopyOptions(
collectionId: string,
collectionName: string
): CopyOption[] {
const baseUrl = 'https://tpmjs.com';
const mcpUrlHttp = `${baseUrl}/mcp/collections/${collectionId}`;
const mcpUrlSse = `${baseUrl}/mcp/collections/${collectionId}/sse`;
const claudeConfig = JSON.stringify(
{
mcpServers: {
[collectionName.toLowerCase().replace(/\s+/g, '-')]: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-remote', mcpUrlSse],
},
},
},
null,
2
);
return [
{ label: 'MCP URL (HTTP)', value: mcpUrlHttp, description: mcpUrlHttp },
{ label: 'MCP URL (SSE)', value: mcpUrlSse, description: mcpUrlSse },
{
label: 'Claude Config',
value: claudeConfig,
description: 'JSON for claude_desktop_config.json',
},
];
}
export function getAgentCopyOptions(agentUid: string, agentName: string): CopyOption[] {
const baseUrl = 'https://tpmjs.com';
const mcpUrl = `${baseUrl}/mcp/agents/${agentUid}`;
const claudeConfig = JSON.stringify(
{
mcpServers: {
[agentName.toLowerCase().replace(/\s+/g, '-')]: {
command: 'npx',
args: ['-y', '@anthropic-ai/mcp-remote', mcpUrl],
},
},
},
null,
2
);
return [
{ label: 'Agent UID', value: agentUid, description: agentUid },
{ label: 'MCP URL', value: mcpUrl, description: mcpUrl },
{
label: 'Claude Config',
value: claudeConfig,
description: 'JSON for claude_desktop_config.json',
},
];
}

View file

@ -0,0 +1,112 @@
'use client';
import { useCallback, useEffect, useState } from 'react';
export type PackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun';
const STORAGE_KEY = 'tpmjs-package-manager';
const packageManagers: { id: PackageManager; label: string }[] = [
{ id: 'npm', label: 'npm' },
{ id: 'yarn', label: 'yarn' },
{ id: 'pnpm', label: 'pnpm' },
{ id: 'bun', label: 'bun' },
];
interface PackageManagerSelectorProps {
value?: PackageManager;
onChange?: (manager: PackageManager) => void;
className?: string;
}
export function PackageManagerSelector({
value,
onChange,
className = '',
}: PackageManagerSelectorProps): React.ReactElement {
const [selected, setSelected] = useState<PackageManager>('npm');
// Load from localStorage on mount
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
if (stored && packageManagers.some((pm) => pm.id === stored)) {
setSelected(stored);
}
}
}, []);
// Sync with controlled value
useEffect(() => {
if (value) {
setSelected(value);
}
}, [value]);
const handleSelect = useCallback(
(manager: PackageManager) => {
setSelected(manager);
localStorage.setItem(STORAGE_KEY, manager);
onChange?.(manager);
},
[onChange]
);
return (
<div className={`inline-flex items-center gap-1 ${className}`}>
<span className="text-sm text-foreground-secondary mr-1">Package Manager:</span>
<div className="inline-flex rounded-lg border border-border overflow-hidden">
{packageManagers.map((pm) => (
<button
key={pm.id}
type="button"
onClick={() => handleSelect(pm.id)}
className={`px-3 py-1.5 text-xs font-medium transition-colors ${
selected === pm.id
? 'bg-primary text-primary-foreground'
: 'bg-background text-foreground-secondary hover:bg-surface hover:text-foreground'
}`}
>
{pm.label}
</button>
))}
</div>
</div>
);
}
export function getInstallCommand(packageName: string, manager: PackageManager): string {
switch (manager) {
case 'npm':
return `npm install ${packageName}`;
case 'yarn':
return `yarn add ${packageName}`;
case 'pnpm':
return `pnpm add ${packageName}`;
case 'bun':
return `bun add ${packageName}`;
default:
return `npm install ${packageName}`;
}
}
// Hook for getting current package manager
export function usePackageManager(): [PackageManager, (manager: PackageManager) => void] {
const [manager, setManager] = useState<PackageManager>('npm');
useEffect(() => {
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(STORAGE_KEY) as PackageManager | null;
if (stored && packageManagers.some((pm) => pm.id === stored)) {
setManager(stored);
}
}
}, []);
const updateManager = useCallback((newManager: PackageManager) => {
setManager(newManager);
localStorage.setItem(STORAGE_KEY, newManager);
}, []);
return [manager, updateManager];
}

View file

@ -894,14 +894,15 @@ export const manualTools: ManualTool[] = [
type: 'object',
description: 'An object containing file paths as keys and file contents as values.',
required: true,
}
},
],
returns: {
type: 'object',
description: 'An object containing the tools available in the created bash environment.',
},
aiAgent: {
useCase: 'Use this tool to execute bash commands and manipulate files within a sandboxed environment.',
useCase:
'Use this tool to execute bash commands and manipulate files within a sandboxed environment.',
examples: ['Create a bash environment with specific files and execute commands.'],
},
docsUrl: 'https://github.com/vercel/bash-tool',

14
pnpm-lock.yaml generated
View file

@ -331,6 +331,9 @@ importers:
resend:
specifier: ^6.6.0
version: 6.6.0
sonner:
specifier: ^2.0.7
version: 2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0)
zod:
specifier: ^4.0.0
version: 4.1.13
@ -10090,6 +10093,12 @@ packages:
resolution: {integrity: sha512-QlaZEqcAH3/RtNyet1IPIYPsEWAaYyXXv1Krsi+1L/QHppjX4Ifm8MQsBISz9vE8cHicIq3clogsheili5vhaQ==}
engines: {node: '>= 18'}
sonner@2.0.7:
resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==}
peerDependencies:
react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@ -18503,6 +18512,11 @@ snapshots:
smol-toml@1.5.2: {}
sonner@2.0.7(react-dom@19.2.0(react@19.2.0))(react@19.2.0):
dependencies:
react: 19.2.0
react-dom: 19.2.0(react@19.2.0)
source-map-js@1.2.1: {}
source-map-support@0.5.21: