From 3c5c218207c76bf28e8e96855d5451be4d98cfcd Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Mon, 19 Jan 2026 05:57:29 +1000 Subject: [PATCH] feat(web): add scenarios UI - explorer, detail, and homepage section - Add global scenarios explorer page at /scenarios - Add scenario detail page with run history at /scenarios/[id] - Add collection-scoped scenario detail page - Add featured scenarios section to homepage - Add useScenarios hook for data fetching - Regenerate CLI manifest --- .../[slug]/scenarios/[id]/page.tsx | 467 +++++ apps/web/src/app/page.tsx | 258 ++- apps/web/src/app/scenarios/[id]/page.tsx | 468 +++++ apps/web/src/app/scenarios/page.tsx | 297 +++ apps/web/src/hooks/useScenarios.ts | 86 + packages/cli/oclif.manifest.json | 1789 +++++++++++++++++ 6 files changed, 3323 insertions(+), 42 deletions(-) create mode 100644 apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/page.tsx create mode 100644 apps/web/src/app/scenarios/[id]/page.tsx create mode 100644 apps/web/src/app/scenarios/page.tsx create mode 100644 apps/web/src/hooks/useScenarios.ts create mode 100644 packages/cli/oclif.manifest.json diff --git a/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/page.tsx b/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/page.tsx new file mode 100644 index 0000000..15c48e1 --- /dev/null +++ b/apps/web/src/app/(profile)/[username]/collections/[slug]/scenarios/[id]/page.tsx @@ -0,0 +1,467 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { notFound, useParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; + +interface ScenarioRun { + id: string; + status: string; + retryCount: number; + evaluator: { + model: string | null; + verdict: string | null; + reason: string | null; + }; + assertions: { passed: string[]; failed: string[] } | null; + usage: { + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + executionTimeMs: number | null; + estimatedCost: number | null; + }; + timestamps: { + startedAt: string | null; + completedAt: string | null; + createdAt: string; + }; + output?: string; + errorLog?: string; + conversation?: unknown[]; +} + +interface ScenarioDetail { + id: string; + collectionId: string | null; + prompt: string; + name: string | null; + description: string | null; + tags: string[]; + qualityScore: number; + consecutivePasses: number; + consecutiveFails: number; + totalRuns: number; + lastRunAt: string | null; + lastRunStatus: string | null; + createdAt: string; + updatedAt: string; + isOwner: boolean; + collection: { + id: string; + name: string; + slug: string; + username: string; + } | null; + recentRuns: ScenarioRun[]; + runCount: number; +} + +function StatusBadge({ status }: { status: string | null }) { + if (!status) { + return Not run; + } + + switch (status) { + case 'pass': + return ( + + + Pass + + ); + case 'fail': + return ( + + + Fail + + ); + case 'error': + return ( + + + Error + + ); + case 'running': + return ( + + + Running + + ); + case 'pending': + return ( + + + Pending + + ); + default: + return {status}; + } +} + +function QualityIndicator({ score, showLabel = true }: { score: number; showLabel?: boolean }) { + const percentage = Math.round(score * 100); + const color = + percentage >= 70 + ? 'text-success' + : percentage >= 40 + ? 'text-warning' + : 'text-foreground-tertiary'; + + return ( +
+ + {percentage}% + {showLabel && quality} +
+ ); +} + +function formatDuration(ms: number | null): string { + if (!ms) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function CollectionScenarioDetailPage(): React.ReactElement { + const params = useParams(); + const rawUsername = params.username as string; + const username = rawUsername.startsWith('@') ? rawUsername.slice(1) : rawUsername; + const slug = params.slug as string; + const scenarioId = params.id as string; + + const [scenario, setScenario] = useState(null); + const [runs, setRuns] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [runError, setRunError] = useState(null); + const [expandedRunId, setExpandedRunId] = useState(null); + + const fetchScenario = useCallback(async () => { + try { + const response = await fetch(`/api/scenarios/${scenarioId}?runsLimit=50`); + if (response.status === 404) { + setError('not_found'); + return; + } + const data = await response.json(); + + if (data.success) { + setScenario(data.data); + setRuns(data.data.recentRuns || []); + } else { + setError(data.error?.message || 'Failed to load scenario'); + } + } catch { + setError('Failed to load scenario'); + } finally { + setIsLoading(false); + } + }, [scenarioId]); + + useEffect(() => { + fetchScenario(); + }, [fetchScenario]); + + const handleRunScenario = async () => { + setIsRunning(true); + setRunError(null); + + try { + const response = await fetch(`/api/scenarios/${scenarioId}/run`, { + method: 'POST', + }); + + if (response.status === 429) { + const data = await response.json(); + setRunError(`Rate limited. Try again in ${Math.ceil(data.retryAfter / 60)} minute(s).`); + return; + } + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error?.message || 'Failed to run scenario'); + } + + // Refresh to show updated status + await fetchScenario(); + } catch (err) { + setRunError(err instanceof Error ? err.message : 'Failed to run scenario'); + } finally { + setIsRunning(false); + } + }; + + if (error === 'not_found') { + notFound(); + } + + return ( +
+ + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : scenario ? ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

+ {scenario.name || 'Unnamed Scenario'} +

+ {scenario.description && ( +

{scenario.description}

+ )} +
+ {scenario.isOwner && ( + + )} +
+ + {runError && ( +
+ {runError} +
+ )} + + {/* Prompt */} +
+

+ Prompt +

+

{scenario.prompt}

+
+ + {/* Stats */} +
+
+
Quality
+ +
+
+
Total Runs
+
{scenario.totalRuns}
+
+
+
Pass Streak
+
+ + {scenario.consecutivePasses} +
+
+
+
Fail Streak
+
+ + {scenario.consecutiveFails} +
+
+
+
Last Status
+ +
+
+ + {/* Tags */} + {scenario.tags.length > 0 && ( +
+ {scenario.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Run History */} +
+

Run History

+ + {runs.length === 0 ? ( +
+ +

No runs yet

+ {scenario.isOwner && ( + + )} +
+ ) : ( +
+ {runs.map((run) => ( +
+ {/* Run Header */} + + + {/* Run Details (Expanded) */} + {expandedRunId === run.id && ( +
+
+ {/* Evaluator */} + {run.evaluator.verdict && ( +
+

+ LLM Evaluation +

+
+ + {run.evaluator.model && ( + + {run.evaluator.model} + + )} +
+ {run.evaluator.reason && ( +

+ {run.evaluator.reason} +

+ )} +
+ )} + + {/* Usage Stats */} +
+

+ Usage +

+
+
+ Duration:{' '} + + {formatDuration(run.usage.executionTimeMs)} + +
+
+ Tokens:{' '} + + {run.usage.totalTokens?.toLocaleString() || '—'} + +
+
+ Retries:{' '} + {run.retryCount} +
+
+
+
+ + {/* Output (if owner) */} + {run.output && ( +
+

+ Output +

+
+                                {run.output}
+                              
+
+ )} + + {/* Error Log (if owner and error) */} + {run.errorLog && ( +
+

+ Error Log +

+
+                                {run.errorLog}
+                              
+
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx index 003299b..71587ae 100644 --- a/apps/web/src/app/page.tsx +++ b/apps/web/src/app/page.tsx @@ -2,6 +2,7 @@ import { prisma } from '@tpmjs/db'; import { Badge } from '@tpmjs/ui/Badge/Badge'; import { Button } from '@tpmjs/ui/Button/Button'; import { Container } from '@tpmjs/ui/Container/Container'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; import { AppHeader } from '../components/AppHeader'; // import { ArchitectureDiagramWrapper } from '../components/home/ArchitectureDiagramWrapper'; @@ -13,41 +14,89 @@ export const dynamic = 'force-dynamic'; async function getHomePageData() { try { // Fetch stats in parallel - const [packageCount, toolCount, featuredTools, categoryStats] = await Promise.all([ - // Total package count - prisma.package.count(), + const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] = + await Promise.all([ + // Total package count + prisma.package.count(), - // Total tool count - prisma.tool.count(), + // Total tool count + prisma.tool.count(), - // Top 6 featured tools by quality score - prisma.tool.findMany({ - orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }], - take: 6, - select: { - id: true, - name: true, - description: true, - qualityScore: true, - package: { - select: { - npmPackageName: true, - category: true, - npmDownloadsLastMonth: true, - isOfficial: true, + // Top 6 featured tools by quality score + prisma.tool.findMany({ + orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }], + take: 6, + select: { + id: true, + name: true, + description: true, + qualityScore: true, + package: { + select: { + npmPackageName: true, + category: true, + npmDownloadsLastMonth: true, + isOfficial: true, + }, }, }, - }, - }), + }), - // Category distribution for stats (group by package category) - prisma.package.groupBy({ - by: ['category'], - _count: { - _all: true, - }, - }), - ]); + // Category distribution for stats (group by package category) + prisma.package.groupBy({ + by: ['category'], + _count: { + _all: true, + }, + }), + + // Featured scenarios - mix of high quality, diverse, and fresh + (async () => { + // Get high quality scenarios + const highQuality = await prisma.scenario.findMany({ + where: { + collection: { isPublic: true }, + qualityScore: { gte: 0.3 }, + totalRuns: { gte: 1 }, + }, + orderBy: { qualityScore: 'desc' }, + take: 3, + include: { + collection: { + select: { + id: true, + name: true, + slug: true, + user: { select: { username: true } }, + }, + }, + }, + }); + + // Get fresh scenarios (excluding already selected) + const seenIds = new Set(highQuality.map((s) => s.id)); + const fresh = await prisma.scenario.findMany({ + where: { + collection: { isPublic: true }, + id: { notIn: Array.from(seenIds) }, + }, + orderBy: { createdAt: 'desc' }, + take: 3, + include: { + collection: { + select: { + id: true, + name: true, + slug: true, + user: { select: { username: true } }, + }, + }, + }, + }); + + return [...highQuality, ...fresh].slice(0, 6); + })(), + ]); return { stats: { @@ -60,6 +109,7 @@ async function getHomePageData() { name: c.category, count: c._count._all, })), + featuredScenarios, }; } catch (error) { console.error('Failed to fetch homepage data:', error); @@ -71,6 +121,7 @@ async function getHomePageData() { }, featuredTools: [], categories: [], + featuredScenarios: [], }; } } @@ -192,6 +243,117 @@ export default async function HomePage(): Promise { + {/* Featured Scenarios Section */} + {data.featuredScenarios.length > 0 && ( +
+ +
+

+ Test Scenarios +

+

+ See how tool collections are tested with AI-generated scenarios. Real execution, + real results. +

+
+ +
+ {data.featuredScenarios.map((scenario) => { + const qualityPercent = Math.round(scenario.qualityScore * 100); + const qualityColor = + qualityPercent >= 70 + ? 'text-success' + : qualityPercent >= 40 + ? 'text-warning' + : 'text-foreground-tertiary'; + + return ( + +
+
+

+ {scenario.name || + (scenario.prompt.length > 50 + ? `${scenario.prompt.slice(0, 50)}...` + : scenario.prompt)} +

+ {scenario.lastRunStatus === 'pass' && ( + + + Pass + + )} + {scenario.lastRunStatus === 'fail' && ( + + + Fail + + )} +
+ +

+ {scenario.prompt} +

+ + {scenario.collection && ( +
+ + {scenario.collection.name} + + + by @{scenario.collection.user.username} + +
+ )} + + {scenario.tags.length > 0 && ( +
+ {scenario.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} + +
+ + + {qualityPercent}% quality + + {scenario.totalRuns} runs +
+
+ + ); + })} +
+ +
+ + + +
+
+
+ )} + {/* Integration Section */}
@@ -243,9 +405,7 @@ export default async function HomePage(): Promise {
Windsurf
-

- agentic ide -

+

agentic ide

@@ -273,21 +433,35 @@ export default async function HomePage(): Promise {
                     {'{'}
-                    {'\n  '}"mcpServers": {'{'}
-                    {'\n    '}"tpmjs": {'{'}
-                    {'\n      '}"command": "npx",
-                    {'\n      '}"args": ["-y", "@anthropic/mcp-remote",
-                    {'\n        '}"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"]
-                    {'\n    '}{'}'}
-                    {'\n  '}{'}'}
-                    {'\n'}{'}'}
+                    {'\n  '}
+                    "mcpServers":{' '}
+                    {'{'}
+                    {'\n    '}
+                    "tpmjs":{' '}
+                    {'{'}
+                    {'\n      '}
+                    "command":{' '}
+                    "npx",{'\n      '}
+                    "args": [
+                    "-y",{' '}
+                    "@anthropic/mcp-remote",{'\n        '}
+                    
+                      "https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"
+                    
+                    ]{'\n    '}
+                    {'}'}
+                    {'\n  '}
+                    {'}'}
+                    {'\n'}
+                    {'}'}
                   

- add to config → instant access to 170+ tools + add to config → instant access to{' '} + 170+ tools

diff --git a/apps/web/src/app/scenarios/[id]/page.tsx b/apps/web/src/app/scenarios/[id]/page.tsx new file mode 100644 index 0000000..3d9345f --- /dev/null +++ b/apps/web/src/app/scenarios/[id]/page.tsx @@ -0,0 +1,468 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { Button } from '@tpmjs/ui/Button/Button'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import Link from 'next/link'; +import { notFound, useParams } from 'next/navigation'; +import { useCallback, useEffect, useState } from 'react'; +import { AppHeader } from '~/components/AppHeader'; + +interface ScenarioRun { + id: string; + status: string; + retryCount: number; + evaluator: { + model: string | null; + verdict: string | null; + reason: string | null; + }; + assertions: { passed: string[]; failed: string[] } | null; + usage: { + inputTokens: number | null; + outputTokens: number | null; + totalTokens: number | null; + executionTimeMs: number | null; + estimatedCost: number | null; + }; + timestamps: { + startedAt: string | null; + completedAt: string | null; + createdAt: string; + }; + output?: string; + errorLog?: string; + conversation?: unknown[]; +} + +interface ScenarioDetail { + id: string; + collectionId: string | null; + prompt: string; + name: string | null; + description: string | null; + tags: string[]; + qualityScore: number; + consecutivePasses: number; + consecutiveFails: number; + totalRuns: number; + lastRunAt: string | null; + lastRunStatus: string | null; + createdAt: string; + updatedAt: string; + isOwner: boolean; + collection: { + id: string; + name: string; + slug: string; + username: string; + } | null; + recentRuns: ScenarioRun[]; + runCount: number; +} + +function StatusBadge({ status }: { status: string | null }) { + if (!status) { + return Not run; + } + + switch (status) { + case 'pass': + return ( + + + Pass + + ); + case 'fail': + return ( + + + Fail + + ); + case 'error': + return ( + + + Error + + ); + case 'running': + return ( + + + Running + + ); + case 'pending': + return ( + + + Pending + + ); + default: + return {status}; + } +} + +function QualityIndicator({ score, showLabel = true }: { score: number; showLabel?: boolean }) { + const percentage = Math.round(score * 100); + const color = + percentage >= 70 + ? 'text-success' + : percentage >= 40 + ? 'text-warning' + : 'text-foreground-tertiary'; + + return ( +
+ + {percentage}% + {showLabel && quality} +
+ ); +} + +function formatDuration(ms: number | null): string { + if (!ms) return '—'; + if (ms < 1000) return `${ms}ms`; + return `${(ms / 1000).toFixed(1)}s`; +} + +function formatDate(dateString: string): string { + return new Date(dateString).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +export default function ScenarioDetailPage(): React.ReactElement { + const params = useParams(); + const scenarioId = params.id as string; + + const [scenario, setScenario] = useState(null); + const [runs, setRuns] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [runError, setRunError] = useState(null); + const [expandedRunId, setExpandedRunId] = useState(null); + + const fetchScenario = useCallback(async () => { + try { + const response = await fetch(`/api/scenarios/${scenarioId}?runsLimit=50`); + if (response.status === 404) { + setError('not_found'); + return; + } + const data = await response.json(); + + if (data.success) { + setScenario(data.data); + setRuns(data.data.recentRuns || []); + } else { + setError(data.error?.message || 'Failed to load scenario'); + } + } catch { + setError('Failed to load scenario'); + } finally { + setIsLoading(false); + } + }, [scenarioId]); + + useEffect(() => { + fetchScenario(); + }, [fetchScenario]); + + const handleRunScenario = async () => { + setIsRunning(true); + setRunError(null); + + try { + const response = await fetch(`/api/scenarios/${scenarioId}/run`, { + method: 'POST', + }); + + if (response.status === 429) { + const data = await response.json(); + setRunError(`Rate limited. Try again in ${Math.ceil(data.retryAfter / 60)} minute(s).`); + return; + } + + if (!response.ok) { + const data = await response.json(); + throw new Error(data.error?.message || 'Failed to run scenario'); + } + + // Refresh to show updated status + await fetchScenario(); + } catch (err) { + setRunError(err instanceof Error ? err.message : 'Failed to run scenario'); + } finally { + setIsRunning(false); + } + }; + + if (error === 'not_found') { + notFound(); + } + + return ( +
+ + +
+ {isLoading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+
+ ) : scenario ? ( +
+ {/* Breadcrumb */} + + + {/* Header */} +
+
+

+ {scenario.name || 'Unnamed Scenario'} +

+ {scenario.description && ( +

{scenario.description}

+ )} +
+ {scenario.isOwner && ( + + )} +
+ + {runError && ( +
+ {runError} +
+ )} + + {/* Prompt */} +
+

+ Prompt +

+

{scenario.prompt}

+
+ + {/* Stats */} +
+
+
Quality
+ +
+
+
Total Runs
+
{scenario.totalRuns}
+
+
+
Pass Streak
+
+ + {scenario.consecutivePasses} +
+
+
+
Fail Streak
+
+ + {scenario.consecutiveFails} +
+
+
+
Last Status
+ +
+
+ + {/* Tags */} + {scenario.tags.length > 0 && ( +
+ {scenario.tags.map((tag) => ( + + {tag} + + ))} +
+ )} + + {/* Run History */} +
+

Run History

+ + {runs.length === 0 ? ( +
+ +

No runs yet

+ {scenario.isOwner && ( + + )} +
+ ) : ( +
+ {runs.map((run) => ( +
+ {/* Run Header */} + + + {/* Run Details (Expanded) */} + {expandedRunId === run.id && ( +
+
+ {/* Evaluator */} + {run.evaluator.verdict && ( +
+

+ LLM Evaluation +

+
+ + {run.evaluator.model && ( + + {run.evaluator.model} + + )} +
+ {run.evaluator.reason && ( +

+ {run.evaluator.reason} +

+ )} +
+ )} + + {/* Usage Stats */} +
+

+ Usage +

+
+
+ Duration:{' '} + + {formatDuration(run.usage.executionTimeMs)} + +
+
+ Tokens:{' '} + + {run.usage.totalTokens?.toLocaleString() || '—'} + +
+
+ Retries:{' '} + {run.retryCount} +
+
+
+
+ + {/* Output (if owner) */} + {run.output && ( +
+

+ Output +

+
+                                {run.output}
+                              
+
+ )} + + {/* Error Log (if owner and error) */} + {run.errorLog && ( +
+

+ Error Log +

+
+                                {run.errorLog}
+                              
+
+ )} +
+ )} +
+ ))} +
+ )} +
+
+ ) : null} +
+
+ ); +} diff --git a/apps/web/src/app/scenarios/page.tsx b/apps/web/src/app/scenarios/page.tsx new file mode 100644 index 0000000..4ccb7cd --- /dev/null +++ b/apps/web/src/app/scenarios/page.tsx @@ -0,0 +1,297 @@ +'use client'; + +import { Badge } from '@tpmjs/ui/Badge/Badge'; +import { EmptyState } from '@tpmjs/ui/EmptyState/EmptyState'; +import { ErrorState } from '@tpmjs/ui/ErrorState/ErrorState'; +import { Icon } from '@tpmjs/ui/Icon/Icon'; +import { Input } from '@tpmjs/ui/Input/Input'; +import { LoadingState } from '@tpmjs/ui/LoadingState/LoadingState'; +import { PageHeader } from '@tpmjs/ui/PageHeader/PageHeader'; +import { Select } from '@tpmjs/ui/Select/Select'; +import Link from 'next/link'; +import { useCallback, useMemo, useState } from 'react'; +import { TableVirtuoso } from 'react-virtuoso'; +import { AppHeader } from '~/components/AppHeader'; +import { type PublicScenario, useScenarios } from '~/hooks/useScenarios'; + +type SortOption = 'qualityScore' | 'totalRuns' | 'createdAt' | 'lastRunAt'; + +function sortScenarios(scenarios: PublicScenario[], sortBy: SortOption): PublicScenario[] { + return [...scenarios].sort((a, b) => { + switch (sortBy) { + case 'qualityScore': + return b.qualityScore - a.qualityScore; + case 'totalRuns': + return b.totalRuns - a.totalRuns; + case 'createdAt': + return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(); + case 'lastRunAt': { + const aTime = a.lastRunAt ? new Date(a.lastRunAt).getTime() : 0; + const bTime = b.lastRunAt ? new Date(b.lastRunAt).getTime() : 0; + return bTime - aTime; + } + default: + return 0; + } + }); +} + +function truncateText(text: string, maxLength: number): string { + if (text.length <= maxLength) return text; + return `${text.slice(0, maxLength).trim()}...`; +} + +function StatusBadge({ status }: { status: string | null }) { + if (!status) { + return ( + + Not run + + ); + } + + switch (status) { + case 'pass': + return ( + + + Pass + + ); + case 'fail': + return ( + + + Fail + + ); + case 'error': + return ( + + + Error + + ); + default: + return ( + + {status} + + ); + } +} + +function QualityScore({ score }: { score: number }) { + const percentage = Math.round(score * 100); + const color = + percentage >= 70 + ? 'text-success' + : percentage >= 40 + ? 'text-warning' + : 'text-foreground-tertiary'; + + return ( +
+ + {percentage}% +
+ ); +} + +export default function ScenariosExplorerPage(): React.ReactElement { + const [search, setSearch] = useState(''); + const [sort, setSort] = useState('qualityScore'); + + // Fetch scenarios using SWR + const { data, isLoading, error: swrError, mutate } = useScenarios({ sortBy: sort }); + + const scenarios = data?.scenarios ?? []; + const hasMore = data?.pagination.hasMore ?? false; + const error = swrError?.message ?? null; + + // Filter and sort scenarios (client-side search) + const filteredScenarios = useMemo(() => { + let result = scenarios; + + if (search) { + const query = search.toLowerCase(); + result = result.filter( + (s: PublicScenario) => + s.prompt.toLowerCase().includes(query) || + s.name?.toLowerCase().includes(query) || + s.tags.some((tag) => tag.toLowerCase().includes(query)) || + s.collection?.name.toLowerCase().includes(query) + ); + } + + return sortScenarios(result, sort); + }, [scenarios, search, sort]); + + const TableHeader = useCallback( + () => ( + + Scenario + Collection + Quality + Runs + Status + Tags + + ), + [] + ); + + const TableRow = useCallback((_index: number, scenario: PublicScenario) => { + const detailUrl = scenario.collection + ? `/@${scenario.collection.username}/collections/${scenario.collection.slug}/scenarios/${scenario.id}` + : `/scenarios/${scenario.id}`; + + return ( + <> + + +
+ {scenario.name || truncateText(scenario.prompt, 50)} +
+ {scenario.name && ( +
+ {truncateText(scenario.prompt, 80)} +
+ )} + + + + {scenario.collection ? ( + + {scenario.collection.name} + + ) : ( + + )} + + + + + + + {scenario.totalRuns} + + + + + + +
+ {scenario.tags.slice(0, 2).map((tag) => ( + + {tag} + + ))} + {scenario.tags.length > 2 && ( + + +{scenario.tags.length - 2} + + )} +
+ + + ); + }, []); + + return ( +
+ + +
+ + + {/* Filters */} +
+
+ setSearch(e.target.value)} + placeholder="Search scenarios..." + /> +
+ +
+ Sort: +