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
This commit is contained in:
Ajax Davis 2026-01-19 05:57:29 +10:00
parent dc684d3b0e
commit 3c5c218207
6 changed files with 3323 additions and 42 deletions

View file

@ -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 <Badge variant="secondary">Not run</Badge>;
}
switch (status) {
case 'pass':
return (
<Badge className="bg-success/10 text-success border-success/20">
<Icon icon="check" className="w-3.5 h-3.5 mr-1" />
Pass
</Badge>
);
case 'fail':
return (
<Badge className="bg-error/10 text-error border-error/20">
<Icon icon="x" className="w-3.5 h-3.5 mr-1" />
Fail
</Badge>
);
case 'error':
return (
<Badge className="bg-warning/10 text-warning border-warning/20">
<Icon icon="alertTriangle" className="w-3.5 h-3.5 mr-1" />
Error
</Badge>
);
case 'running':
return (
<Badge className="bg-primary/10 text-primary border-primary/20">
<Icon icon="loader" className="w-3.5 h-3.5 mr-1 animate-spin" />
Running
</Badge>
);
case 'pending':
return (
<Badge variant="secondary">
<Icon icon="clock" className="w-3.5 h-3.5 mr-1" />
Pending
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
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 (
<div className="flex items-center gap-1.5" title={`Quality score: ${percentage}%`}>
<Icon icon="star" className={`w-4 h-4 ${color}`} />
<span className={`font-medium ${color}`}>{percentage}%</span>
{showLabel && <span className="text-foreground-tertiary">quality</span>}
</div>
);
}
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<ScenarioDetail | null>(null);
const [runs, setRuns] = useState<ScenarioRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [expandedRunId, setExpandedRunId] = useState<string | null>(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 (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-5xl mx-auto px-4 py-8">
{isLoading ? (
<div className="flex justify-center py-12">
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-error">{error}</p>
</div>
) : scenario ? (
<div className="space-y-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary">
<Link href={`/@${username}`} className="hover:text-foreground transition-colors">
@{username}
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<Link
href={`/@${username}/collections/${slug}`}
className="hover:text-foreground transition-colors"
>
{scenario.collection?.name || slug}
</Link>
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">{scenario.name || 'Scenario'}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground">
{scenario.name || 'Unnamed Scenario'}
</h1>
{scenario.description && (
<p className="text-foreground-secondary mt-2">{scenario.description}</p>
)}
</div>
{scenario.isOwner && (
<Button onClick={handleRunScenario} disabled={isRunning}>
{isRunning ? (
<>
<Icon icon="loader" className="w-4 h-4 mr-1.5 animate-spin" />
Running...
</>
) : (
<>
<Icon icon="arrowRight" className="w-4 h-4 mr-1.5" />
Run Scenario
</>
)}
</Button>
)}
</div>
{runError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error">
{runError}
</div>
)}
{/* Prompt */}
<div className="p-4 bg-surface border border-border rounded-xl">
<h2 className="text-sm font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Prompt
</h2>
<p className="text-foreground whitespace-pre-wrap">{scenario.prompt}</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Quality</div>
<QualityIndicator score={scenario.qualityScore} showLabel={false} />
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Total Runs</div>
<div className="text-xl font-semibold text-foreground">{scenario.totalRuns}</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Pass Streak</div>
<div className="text-xl font-semibold text-success flex items-center gap-1">
<Icon icon="check" className="w-5 h-5" />
{scenario.consecutivePasses}
</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Fail Streak</div>
<div className="text-xl font-semibold text-error flex items-center gap-1">
<Icon icon="x" className="w-5 h-5" />
{scenario.consecutiveFails}
</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Last Status</div>
<StatusBadge status={scenario.lastRunStatus} />
</div>
</div>
{/* Tags */}
{scenario.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{scenario.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
)}
{/* Run History */}
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">Run History</h2>
{runs.length === 0 ? (
<div className="p-8 bg-surface border border-border rounded-xl text-center">
<Icon icon="clock" className="w-8 h-8 mx-auto text-foreground-tertiary mb-3" />
<p className="text-foreground-secondary">No runs yet</p>
{scenario.isOwner && (
<Button className="mt-4" onClick={handleRunScenario} disabled={isRunning}>
Run Scenario
</Button>
)}
</div>
) : (
<div className="space-y-3">
{runs.map((run) => (
<div
key={run.id}
className="bg-surface border border-border rounded-xl overflow-hidden"
>
{/* Run Header */}
<button
type="button"
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
className="w-full p-4 flex items-center justify-between hover:bg-surface-secondary transition-colors"
>
<div className="flex items-center gap-4">
<StatusBadge status={run.status} />
<span className="text-sm text-foreground-secondary">
{formatDate(run.timestamps.createdAt)}
</span>
{run.usage.executionTimeMs && (
<span className="text-sm text-foreground-tertiary">
{formatDuration(run.usage.executionTimeMs)}
</span>
)}
{run.usage.totalTokens && (
<span className="text-sm text-foreground-tertiary">
{run.usage.totalTokens.toLocaleString()} tokens
</span>
)}
</div>
<Icon
icon={expandedRunId === run.id ? 'chevronDown' : 'chevronRight'}
className="w-5 h-5 text-foreground-tertiary"
/>
</button>
{/* Run Details (Expanded) */}
{expandedRunId === run.id && (
<div className="px-4 pb-4 border-t border-border/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{/* Evaluator */}
{run.evaluator.verdict && (
<div className="p-3 bg-surface-secondary rounded-lg">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
LLM Evaluation
</h4>
<div className="flex items-center gap-2 mb-2">
<StatusBadge status={run.evaluator.verdict} />
{run.evaluator.model && (
<Badge variant="secondary" size="sm">
{run.evaluator.model}
</Badge>
)}
</div>
{run.evaluator.reason && (
<p className="text-sm text-foreground-secondary">
{run.evaluator.reason}
</p>
)}
</div>
)}
{/* Usage Stats */}
<div className="p-3 bg-surface-secondary rounded-lg">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Usage
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-foreground-tertiary">Duration:</span>{' '}
<span className="text-foreground">
{formatDuration(run.usage.executionTimeMs)}
</span>
</div>
<div>
<span className="text-foreground-tertiary">Tokens:</span>{' '}
<span className="text-foreground">
{run.usage.totalTokens?.toLocaleString() || '—'}
</span>
</div>
<div>
<span className="text-foreground-tertiary">Retries:</span>{' '}
<span className="text-foreground">{run.retryCount}</span>
</div>
</div>
</div>
</div>
{/* Output (if owner) */}
{run.output && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Output
</h4>
<pre className="p-3 bg-surface-secondary rounded-lg text-sm text-foreground overflow-x-auto whitespace-pre-wrap">
{run.output}
</pre>
</div>
)}
{/* Error Log (if owner and error) */}
{run.errorLog && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-error uppercase tracking-wide mb-2">
Error Log
</h4>
<pre className="p-3 bg-error/5 border border-error/20 rounded-lg text-sm text-error overflow-x-auto whitespace-pre-wrap">
{run.errorLog}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
) : null}
</main>
</div>
);
}

View file

@ -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<React.ReactElement> {
</Container>
</section>
{/* Featured Scenarios Section */}
{data.featuredScenarios.length > 0 && (
<section className="py-16 bg-surface border-t border-border">
<Container size="xl" padding="lg">
<div className="text-center mb-12">
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-foreground">
Test Scenarios
</h2>
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto mb-8">
See how tool collections are tested with AI-generated scenarios. Real execution,
real results.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 md:gap-6 mb-12">
{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 (
<Link
key={scenario.id}
href={
scenario.collection
? `/@${scenario.collection.user.username}/collections/${scenario.collection.slug}/scenarios/${scenario.id}`
: `/scenarios/${scenario.id}`
}
className="group"
>
<div className="p-6 border border-border rounded-lg bg-background hover:border-foreground transition-colors h-full flex flex-col">
<div className="flex items-start justify-between gap-2 mb-3">
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors min-w-0 break-words line-clamp-1">
{scenario.name ||
(scenario.prompt.length > 50
? `${scenario.prompt.slice(0, 50)}...`
: scenario.prompt)}
</h3>
{scenario.lastRunStatus === 'pass' && (
<Badge
size="sm"
className="flex-shrink-0 bg-success/10 text-success border-success/20"
>
<Icon icon="check" className="w-3 h-3 mr-1" />
Pass
</Badge>
)}
{scenario.lastRunStatus === 'fail' && (
<Badge
size="sm"
className="flex-shrink-0 bg-error/10 text-error border-error/20"
>
<Icon icon="x" className="w-3 h-3 mr-1" />
Fail
</Badge>
)}
</div>
<p className="text-sm text-foreground-secondary mb-4 flex-1 line-clamp-2">
{scenario.prompt}
</p>
{scenario.collection && (
<div className="flex items-center gap-2 mb-4">
<Badge variant="outline" size="sm">
{scenario.collection.name}
</Badge>
<span className="text-xs text-foreground-tertiary">
by @{scenario.collection.user.username}
</span>
</div>
)}
{scenario.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mb-4">
{scenario.tags.slice(0, 3).map((tag) => (
<Badge key={tag} variant="secondary" size="sm" className="text-xs">
{tag}
</Badge>
))}
</div>
)}
<div className="mt-auto pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
<span className={`flex items-center gap-1 ${qualityColor}`}>
<Icon icon="star" className="w-3.5 h-3.5" />
{qualityPercent}% quality
</span>
<span>{scenario.totalRuns} runs</span>
</div>
</div>
</Link>
);
})}
</div>
<div className="flex justify-center">
<Link href="/scenarios">
<Button size="lg" variant="outline">
Browse All Scenarios
</Button>
</Link>
</div>
</Container>
</section>
)}
{/* Integration Section */}
<section className="py-20 bg-surface border-y border-border">
<Container size="xl" padding="lg">
@ -243,9 +405,7 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="font-mono text-lg font-medium text-foreground group-hover:text-primary transition-colors">
Windsurf
</div>
<p className="font-mono text-xs text-foreground-tertiary mt-1">
agentic ide
</p>
<p className="font-mono text-xs text-foreground-tertiary mt-1">agentic ide</p>
</div>
<div className="group p-6 border border-dashed border-border hover:border-primary hover:bg-primary/5 transition-all">
<div className="w-10 h-10 bg-primary/10 flex items-center justify-center mb-4">
@ -273,21 +433,35 @@ export default async function HomePage(): Promise<React.ReactElement> {
<div className="bg-background border border-border p-4 font-mono text-sm overflow-x-auto">
<pre className="text-foreground">
<span className="text-foreground-tertiary">{'{'}</span>
{'\n '}<span className="text-primary">"mcpServers"</span>: <span className="text-foreground-tertiary">{'{'}</span>
{'\n '}<span className="text-primary">"tpmjs"</span>: <span className="text-foreground-tertiary">{'{'}</span>
{'\n '}<span className="text-primary">"command"</span>: <span className="text-success">"npx"</span>,
{'\n '}<span className="text-primary">"args"</span>: [<span className="text-success">"-y"</span>, <span className="text-success">"@anthropic/mcp-remote"</span>,
{'\n '}<span className="text-success">"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"</span>]
{'\n '}<span className="text-foreground-tertiary">{'}'}</span>
{'\n '}<span className="text-foreground-tertiary">{'}'}</span>
{'\n'}<span className="text-foreground-tertiary">{'}'}</span>
{'\n '}
<span className="text-primary">"mcpServers"</span>:{' '}
<span className="text-foreground-tertiary">{'{'}</span>
{'\n '}
<span className="text-primary">"tpmjs"</span>:{' '}
<span className="text-foreground-tertiary">{'{'}</span>
{'\n '}
<span className="text-primary">"command"</span>:{' '}
<span className="text-success">"npx"</span>,{'\n '}
<span className="text-primary">"args"</span>: [
<span className="text-success">"-y"</span>,{' '}
<span className="text-success">"@anthropic/mcp-remote"</span>,{'\n '}
<span className="text-success">
"https://tpmjs.com/api/mcp/ajax/ajax-collection/sse"
</span>
]{'\n '}
<span className="text-foreground-tertiary">{'}'}</span>
{'\n '}
<span className="text-foreground-tertiary">{'}'}</span>
{'\n'}
<span className="text-foreground-tertiary">{'}'}</span>
</pre>
</div>
</div>
<div className="flex items-center gap-3 pt-4 border-t border-dashed border-border">
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
<p className="font-mono text-xs text-foreground-secondary">
add to config instant access to <span className="text-primary font-medium">170+ tools</span>
add to config instant access to{' '}
<span className="text-primary font-medium">170+ tools</span>
</p>
</div>
</fieldset>

View file

@ -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 <Badge variant="secondary">Not run</Badge>;
}
switch (status) {
case 'pass':
return (
<Badge className="bg-success/10 text-success border-success/20">
<Icon icon="check" className="w-3.5 h-3.5 mr-1" />
Pass
</Badge>
);
case 'fail':
return (
<Badge className="bg-error/10 text-error border-error/20">
<Icon icon="x" className="w-3.5 h-3.5 mr-1" />
Fail
</Badge>
);
case 'error':
return (
<Badge className="bg-warning/10 text-warning border-warning/20">
<Icon icon="alertTriangle" className="w-3.5 h-3.5 mr-1" />
Error
</Badge>
);
case 'running':
return (
<Badge className="bg-primary/10 text-primary border-primary/20">
<Icon icon="loader" className="w-3.5 h-3.5 mr-1 animate-spin" />
Running
</Badge>
);
case 'pending':
return (
<Badge variant="secondary">
<Icon icon="clock" className="w-3.5 h-3.5 mr-1" />
Pending
</Badge>
);
default:
return <Badge variant="secondary">{status}</Badge>;
}
}
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 (
<div className="flex items-center gap-1.5" title={`Quality score: ${percentage}%`}>
<Icon icon="star" className={`w-4 h-4 ${color}`} />
<span className={`font-medium ${color}`}>{percentage}%</span>
{showLabel && <span className="text-foreground-tertiary">quality</span>}
</div>
);
}
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<ScenarioDetail | null>(null);
const [runs, setRuns] = useState<ScenarioRun[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [runError, setRunError] = useState<string | null>(null);
const [expandedRunId, setExpandedRunId] = useState<string | null>(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 (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-5xl mx-auto px-4 py-8">
{isLoading ? (
<div className="flex justify-center py-12">
<Icon icon="loader" className="w-8 h-8 animate-spin text-foreground-secondary" />
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-error">{error}</p>
</div>
) : scenario ? (
<div className="space-y-8">
{/* Breadcrumb */}
<nav className="flex items-center gap-2 text-sm text-foreground-secondary">
<Link href="/scenarios" className="hover:text-foreground transition-colors">
Scenarios
</Link>
{scenario.collection && (
<>
<Icon icon="chevronRight" className="w-4 h-4" />
<Link
href={`/@${scenario.collection.username}/collections/${scenario.collection.slug}`}
className="hover:text-foreground transition-colors"
>
{scenario.collection.name}
</Link>
</>
)}
<Icon icon="chevronRight" className="w-4 h-4" />
<span className="text-foreground">{scenario.name || 'Scenario'}</span>
</nav>
{/* Header */}
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<h1 className="text-2xl font-bold text-foreground">
{scenario.name || 'Unnamed Scenario'}
</h1>
{scenario.description && (
<p className="text-foreground-secondary mt-2">{scenario.description}</p>
)}
</div>
{scenario.isOwner && (
<Button onClick={handleRunScenario} disabled={isRunning}>
{isRunning ? (
<>
<Icon icon="loader" className="w-4 h-4 mr-1.5 animate-spin" />
Running...
</>
) : (
<>
<Icon icon="arrowRight" className="w-4 h-4 mr-1.5" />
Run Scenario
</>
)}
</Button>
)}
</div>
{runError && (
<div className="p-3 bg-error/10 border border-error/20 rounded-lg text-sm text-error">
{runError}
</div>
)}
{/* Prompt */}
<div className="p-4 bg-surface border border-border rounded-xl">
<h2 className="text-sm font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Prompt
</h2>
<p className="text-foreground whitespace-pre-wrap">{scenario.prompt}</p>
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Quality</div>
<QualityIndicator score={scenario.qualityScore} showLabel={false} />
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Total Runs</div>
<div className="text-xl font-semibold text-foreground">{scenario.totalRuns}</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Pass Streak</div>
<div className="text-xl font-semibold text-success flex items-center gap-1">
<Icon icon="check" className="w-5 h-5" />
{scenario.consecutivePasses}
</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Fail Streak</div>
<div className="text-xl font-semibold text-error flex items-center gap-1">
<Icon icon="x" className="w-5 h-5" />
{scenario.consecutiveFails}
</div>
</div>
<div className="p-4 bg-surface border border-border rounded-xl">
<div className="text-sm text-foreground-secondary mb-1">Last Status</div>
<StatusBadge status={scenario.lastRunStatus} />
</div>
</div>
{/* Tags */}
{scenario.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{scenario.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
)}
{/* Run History */}
<section>
<h2 className="text-lg font-semibold text-foreground mb-4">Run History</h2>
{runs.length === 0 ? (
<div className="p-8 bg-surface border border-border rounded-xl text-center">
<Icon icon="clock" className="w-8 h-8 mx-auto text-foreground-tertiary mb-3" />
<p className="text-foreground-secondary">No runs yet</p>
{scenario.isOwner && (
<Button className="mt-4" onClick={handleRunScenario} disabled={isRunning}>
Run Scenario
</Button>
)}
</div>
) : (
<div className="space-y-3">
{runs.map((run) => (
<div
key={run.id}
className="bg-surface border border-border rounded-xl overflow-hidden"
>
{/* Run Header */}
<button
type="button"
onClick={() => setExpandedRunId(expandedRunId === run.id ? null : run.id)}
className="w-full p-4 flex items-center justify-between hover:bg-surface-secondary transition-colors"
>
<div className="flex items-center gap-4">
<StatusBadge status={run.status} />
<span className="text-sm text-foreground-secondary">
{formatDate(run.timestamps.createdAt)}
</span>
{run.usage.executionTimeMs && (
<span className="text-sm text-foreground-tertiary">
{formatDuration(run.usage.executionTimeMs)}
</span>
)}
{run.usage.totalTokens && (
<span className="text-sm text-foreground-tertiary">
{run.usage.totalTokens.toLocaleString()} tokens
</span>
)}
</div>
<Icon
icon={expandedRunId === run.id ? 'chevronDown' : 'chevronRight'}
className="w-5 h-5 text-foreground-tertiary"
/>
</button>
{/* Run Details (Expanded) */}
{expandedRunId === run.id && (
<div className="px-4 pb-4 border-t border-border/50">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
{/* Evaluator */}
{run.evaluator.verdict && (
<div className="p-3 bg-surface-secondary rounded-lg">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
LLM Evaluation
</h4>
<div className="flex items-center gap-2 mb-2">
<StatusBadge status={run.evaluator.verdict} />
{run.evaluator.model && (
<Badge variant="secondary" size="sm">
{run.evaluator.model}
</Badge>
)}
</div>
{run.evaluator.reason && (
<p className="text-sm text-foreground-secondary">
{run.evaluator.reason}
</p>
)}
</div>
)}
{/* Usage Stats */}
<div className="p-3 bg-surface-secondary rounded-lg">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Usage
</h4>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-foreground-tertiary">Duration:</span>{' '}
<span className="text-foreground">
{formatDuration(run.usage.executionTimeMs)}
</span>
</div>
<div>
<span className="text-foreground-tertiary">Tokens:</span>{' '}
<span className="text-foreground">
{run.usage.totalTokens?.toLocaleString() || '—'}
</span>
</div>
<div>
<span className="text-foreground-tertiary">Retries:</span>{' '}
<span className="text-foreground">{run.retryCount}</span>
</div>
</div>
</div>
</div>
{/* Output (if owner) */}
{run.output && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
Output
</h4>
<pre className="p-3 bg-surface-secondary rounded-lg text-sm text-foreground overflow-x-auto whitespace-pre-wrap">
{run.output}
</pre>
</div>
)}
{/* Error Log (if owner and error) */}
{run.errorLog && (
<div className="mt-4">
<h4 className="text-xs font-semibold text-error uppercase tracking-wide mb-2">
Error Log
</h4>
<pre className="p-3 bg-error/5 border border-error/20 rounded-lg text-sm text-error overflow-x-auto whitespace-pre-wrap">
{run.errorLog}
</pre>
</div>
)}
</div>
)}
</div>
))}
</div>
)}
</section>
</div>
) : null}
</main>
</div>
);
}

View file

@ -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 (
<Badge variant="secondary" size="sm">
Not run
</Badge>
);
}
switch (status) {
case 'pass':
return (
<Badge size="sm" className="bg-success/10 text-success border-success/20">
<Icon icon="check" size="xs" className="mr-1" />
Pass
</Badge>
);
case 'fail':
return (
<Badge size="sm" className="bg-error/10 text-error border-error/20">
<Icon icon="x" size="xs" className="mr-1" />
Fail
</Badge>
);
case 'error':
return (
<Badge size="sm" className="bg-warning/10 text-warning border-warning/20">
<Icon icon="alertTriangle" size="xs" className="mr-1" />
Error
</Badge>
);
default:
return (
<Badge variant="secondary" size="sm">
{status}
</Badge>
);
}
}
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 (
<div className="flex items-center gap-1" title={`Quality score: ${percentage}%`}>
<Icon icon="star" size="xs" className={color} />
<span className={`text-sm ${color}`}>{percentage}%</span>
</div>
);
}
export default function ScenariosExplorerPage(): React.ReactElement {
const [search, setSearch] = useState('');
const [sort, setSort] = useState<SortOption>('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(
() => (
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
<th className="px-4 py-3 w-[300px]">Scenario</th>
<th className="px-4 py-3 w-[200px]">Collection</th>
<th className="px-4 py-3 w-[100px] text-center">Quality</th>
<th className="px-4 py-3 w-[80px] text-center">Runs</th>
<th className="px-4 py-3 w-[100px] text-center">Status</th>
<th className="px-4 py-3 w-[150px]">Tags</th>
</tr>
),
[]
);
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 (
<>
<td className="px-4 py-3">
<Link href={detailUrl} className="block group">
<div className="font-semibold text-foreground group-hover:text-primary transition-colors">
{scenario.name || truncateText(scenario.prompt, 50)}
</div>
{scenario.name && (
<div className="text-sm text-foreground-secondary mt-0.5 line-clamp-1">
{truncateText(scenario.prompt, 80)}
</div>
)}
</Link>
</td>
<td className="px-4 py-3">
{scenario.collection ? (
<Link
href={`/@${scenario.collection.username}/collections/${scenario.collection.slug}`}
className="text-sm text-foreground-secondary hover:text-primary transition-colors"
>
{scenario.collection.name}
</Link>
) : (
<span className="text-sm text-foreground-tertiary"></span>
)}
</td>
<td className="px-4 py-3 text-center">
<QualityScore score={scenario.qualityScore} />
</td>
<td className="px-4 py-3 text-center">
<Badge variant="secondary" size="sm">
{scenario.totalRuns}
</Badge>
</td>
<td className="px-4 py-3 text-center">
<StatusBadge status={scenario.lastRunStatus} />
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-1">
{scenario.tags.slice(0, 2).map((tag) => (
<Badge key={tag} variant="secondary" size="sm" className="text-xs">
{tag}
</Badge>
))}
{scenario.tags.length > 2 && (
<Badge variant="secondary" size="sm" className="text-xs">
+{scenario.tags.length - 2}
</Badge>
)}
</div>
</td>
</>
);
}, []);
return (
<div className="min-h-screen bg-background">
<AppHeader />
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<PageHeader
title="Test Scenarios"
description="Explore AI-generated test scenarios that validate tool collections"
/>
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-4 mb-6">
<div className="flex-1">
<Input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search scenarios..."
/>
</div>
<div className="flex items-center gap-2">
<span className="text-sm text-foreground-secondary">Sort:</span>
<Select
value={sort}
onChange={(e) => setSort(e.target.value as SortOption)}
options={[
{ value: 'qualityScore', label: 'Highest Quality' },
{ value: 'totalRuns', label: 'Most Runs' },
{ value: 'createdAt', label: 'Most Recent' },
{ value: 'lastRunAt', label: 'Recently Run' },
]}
/>
</div>
</div>
{/* Content */}
{error ? (
<ErrorState message={error} onRetry={() => mutate()} />
) : isLoading ? (
<LoadingState message="Loading scenarios..." size="lg" />
) : filteredScenarios.length === 0 ? (
<EmptyState
icon="terminal"
title="No scenarios found"
description={
search
? 'Try adjusting your search terms'
: 'No test scenarios have been created yet. Create a collection and generate scenarios!'
}
/>
) : (
<>
<div className="border border-border rounded-lg overflow-hidden">
<TableVirtuoso
style={{ height: 'calc(100vh - 350px)', minHeight: '400px' }}
data={filteredScenarios}
overscan={30}
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-secondary sticky top-0 z-10" />
),
TableBody: (props) => <tbody {...props} />,
TableRow: (props) => (
<tr
{...props}
className="border-b border-border bg-surface hover:bg-surface-secondary transition-all duration-150 group"
/>
),
}}
/>
</div>
<div className="mt-4 text-sm text-foreground-tertiary">
Showing {filteredScenarios.length} scenario
{filteredScenarios.length !== 1 ? 's' : ''}
{search && ` matching "${search}"`}
{hasMore && ' (scroll for more)'}
</div>
</>
)}
</main>
</div>
);
}

View file

@ -0,0 +1,86 @@
import useSWR from 'swr';
export interface PublicScenario {
id: string;
collectionId: string | null;
prompt: string;
name: string | null;
description: string | null;
tags: string[];
qualityScore: number;
totalRuns: number;
lastRunAt: string | null;
lastRunStatus: string | null;
createdAt: string;
collection: {
id: string;
name: string;
slug: string;
username: string;
} | null;
runCount: number;
}
export interface UseScenariosParams {
sortBy?: 'qualityScore' | 'totalRuns' | 'createdAt' | 'lastRunAt';
limit?: number;
offset?: number;
tags?: string[];
}
interface ScenariosResponse {
scenarios: PublicScenario[];
pagination: {
hasMore: boolean;
limit: number;
offset: number;
};
}
/**
* Fetch public scenarios
*/
export function useScenarios(params: UseScenariosParams = {}) {
const searchParams = new URLSearchParams();
searchParams.set('limit', String(params.limit ?? 100));
searchParams.set('offset', String(params.offset ?? 0));
if (params.sortBy) {
searchParams.set('sortBy', params.sortBy);
}
if (params.tags && params.tags.length > 0) {
searchParams.set('tags', params.tags.join(','));
}
const queryString = searchParams.toString();
return useSWR<ScenariosResponse>(`/api/scenarios?${queryString}`, async (url: string) => {
const res = await fetch(url);
const json = await res.json();
if (!json.success) {
throw new Error(json.error?.message || 'Failed to fetch scenarios');
}
return {
scenarios: json.data,
pagination: json.pagination,
};
});
}
/**
* Fetch featured scenarios for homepage
*/
export function useFeaturedScenarios(limit = 6) {
return useSWR<PublicScenario[]>(`/api/scenarios/featured?limit=${limit}`, async (url: string) => {
const res = await fetch(url);
const json = await res.json();
if (!json.success) {
throw new Error(json.error?.message || 'Failed to fetch featured scenarios');
}
return json.data;
});
}

File diff suppressed because it is too large Load diff