Add objective deep-dive pages under eval recommendations

This commit is contained in:
Michael Preuss 2026-02-12 21:21:14 -08:00
parent 1d03382f26
commit 268b183dee
14 changed files with 1026 additions and 56 deletions

View file

@ -0,0 +1,631 @@
"use client"
import { useCallback, useMemo, useState } from "react"
import Link from "next/link"
import { useSearchParams } from "next/navigation"
import { motion } from "framer-motion"
import { ArrowRight, BarChart3, Copy, ExternalLink, SlidersHorizontal, Sparkles, Workflow } from "lucide-react"
import { Cell, ResponsiveContainer, Scatter, ScatterChart, Tooltip, XAxis, YAxis, ZAxis } from "recharts"
import type { RoleRecommendation, ModelCandidate } from "@/lib/mock-recommendations"
import { getCloudSetupUrl } from "@/lib/mock-recommendations"
type EvalOptimizationMode = "best" | "fastest" | "cost"
type ObjectiveDeepDive = {
id: string
slug: string
name: string
description: string
whyItWorks: string[]
recommendedRoleIds: string[]
builderProfile?: {
title: string
description: string
examplePrompt?: string
howItWorks: string[]
}
}
type ObjectiveRoleRec = {
roleId: string
recommendation: RoleRecommendation
}
type Props = {
objective: ObjectiveDeepDive
initialMode: EvalOptimizationMode
recs: ObjectiveRoleRec[]
}
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: { staggerChildren: 0.08, delayChildren: 0.08 },
},
}
const fadeUp = {
hidden: { opacity: 0, y: 14 },
visible: { opacity: 1, y: 0, transition: { duration: 0.55, ease: [0.21, 0.45, 0.27, 0.9] as const } },
}
function isEvalOptimizationMode(value: string): value is EvalOptimizationMode {
return value === "best" || value === "fastest" || value === "cost"
}
function pickCandidate(rec: RoleRecommendation | undefined, mode: EvalOptimizationMode): ModelCandidate | null {
if (!rec) return null
if (mode === "fastest") return rec.speedHire ?? rec.best[0] ?? null
if (mode === "cost") return rec.budgetHire ?? rec.best[0] ?? null
return rec.best[0] ?? null
}
function shortProvider(provider: string) {
switch (provider) {
case "openai":
return "OpenAI"
case "anthropic":
return "Anthropic"
case "google":
return "Google"
case "xai":
return "xAI"
case "deepseek":
return "DeepSeek"
case "moonshot":
return "Moonshot"
default:
return provider
}
}
function formatDollars(value: number) {
if (!Number.isFinite(value)) return "—"
return `$${Math.round(value)}`
}
function formatSeconds(value: number) {
if (!Number.isFinite(value)) return "—"
return `${value.toFixed(1)}s`
}
function buildObjectiveQueryString(
searchParams: { get(name: string): string | null },
objectiveSlug: string,
mode: EvalOptimizationMode,
) {
const params = new URLSearchParams()
params.set("objective", objectiveSlug)
params.set("mode", mode)
// Preserve any existing query bits we might add later without breaking URLs.
const view = searchParams.get("view")
if (view) params.set("view", view)
return `?${params.toString()}`
}
type ObjectiveTooltipPayloadEntry = { payload?: unknown }
function ObjectiveTooltip({ active, payload }: { active?: boolean; payload?: ObjectiveTooltipPayloadEntry[] }) {
if (!active || !payload?.length) return null
const entryPayload = payload[0]?.payload
const p = entryPayload as
| {
name?: string
provider?: string
score?: number
dailyCost?: number
successRate?: number
}
| undefined
if (!p) return null
return (
<div className="rounded-xl border border-border/50 bg-card/95 p-3 shadow-2xl backdrop-blur-md">
<p className="text-sm font-semibold tracking-tight">{p.name}</p>
<p className="mt-1 text-xs text-muted-foreground">
{shortProvider(p.provider ?? "")} · <span className="font-mono">{p.score}</span> score ·{" "}
<span className="font-mono">{p.successRate}%</span> success
</p>
<p className="mt-1 text-xs text-muted-foreground">
Est daily cost <span className="font-mono">{formatDollars(p.dailyCost ?? NaN)}</span>
</p>
</div>
)
}
export function ObjectiveContent({ objective, initialMode, recs }: Props) {
const searchParams = useSearchParams()
const recByRole = useMemo(() => new Map(recs.map((r) => [r.roleId, r.recommendation])), [recs])
const lineup = useMemo(() => objective.recommendedRoleIds.filter((id) => recByRole.has(id)), [objective, recByRole])
const [mode, setMode] = useState<EvalOptimizationMode>(() => {
const m = searchParams.get("mode")
if (m && isEvalOptimizationMode(m)) return m
return initialMode
})
const [overrides, setOverrides] = useState<Record<string, string>>({})
const selectedByRole = useMemo(() => {
const next = new Map<string, ModelCandidate | null>()
for (const roleId of lineup) {
const rec = recByRole.get(roleId)
const overrideModelId = overrides[roleId]
if (overrideModelId && rec) {
next.set(roleId, rec.allCandidates.find((c) => c.modelId === overrideModelId) ?? null)
} else {
next.set(roleId, pickCandidate(rec, mode))
}
}
return next
}, [lineup, mode, overrides, recByRole])
const primaryRoleId = lineup[0] ?? null
const primaryCandidate = primaryRoleId ? (selectedByRole.get(primaryRoleId) ?? null) : null
const modePill = useMemo(
() => [
{ id: "best" as const, label: "Quality" },
{ id: "fastest" as const, label: "Speed" },
{ id: "cost" as const, label: "Cost" },
],
[],
)
const cloudHref = useMemo(() => {
if (!primaryCandidate) return "https://app.roocode.com"
return getCloudSetupUrl(primaryCandidate)
}, [primaryCandidate])
const examplePrompt = objective.builderProfile?.examplePrompt?.trim() ?? ""
const copyPrompt = useCallback(async () => {
if (!examplePrompt) return
await navigator.clipboard.writeText(examplePrompt)
}, [examplePrompt])
const onSelectModel = useCallback((roleId: string, modelId: string) => {
setOverrides((prev) => ({ ...prev, [roleId]: modelId }))
}, [])
const onSetMode = useCallback((next: EvalOptimizationMode) => {
setMode(next)
}, [])
const roleQuery = useMemo(
() => buildObjectiveQueryString(searchParams, objective.slug, mode),
[mode, objective.slug, searchParams],
)
const primaryScatter = useMemo(() => {
if (!primaryRoleId) return []
const rec = recByRole.get(primaryRoleId)
if (!rec) return []
return rec.allCandidates.map((c) => ({
name: c.displayName,
provider: c.provider,
score: c.compositeScore,
successRate: c.successRate,
dailyCost: Math.round(c.estimatedDailyCost),
dotSize: Math.round(40 + (c.successRate / 100) * 260),
isSelected: primaryCandidate?.modelId === c.modelId,
}))
}, [primaryCandidate?.modelId, primaryRoleId, recByRole])
return (
<div className="relative">
{/* Atmosphere */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 overflow-hidden">
<div className="absolute -left-32 -top-40 h-[560px] w-[560px] rounded-full bg-foreground/[0.035] blur-3xl" />
<div className="absolute right-[-180px] top-24 h-[700px] w-[700px] rounded-full bg-foreground/[0.03] blur-3xl" />
<div
className="absolute inset-0 opacity-[0.45]"
style={{
backgroundImage:
"linear-gradient(to right, rgba(255,255,255,0.06) 1px, transparent 1px), linear-gradient(to bottom, rgba(255,255,255,0.06) 1px, transparent 1px)",
backgroundSize: "88px 88px",
}}
/>
</div>
<section className="relative pt-24">
<div className="container relative z-10 mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8">
<motion.div initial="hidden" animate="visible" variants={containerVariants}>
<motion.nav
className="mb-6 flex flex-wrap items-center gap-2 text-sm text-muted-foreground"
variants={fadeUp}>
<Link href="/evals" className="transition-colors hover:text-foreground">
Evals
</Link>
<span className="text-border">/</span>
<Link href="/evals/recommendations" className="transition-colors hover:text-foreground">
Build with Roo Code Cloud
</Link>
<span className="text-border">/</span>
<span className="font-medium text-foreground">{objective.name}</span>
</motion.nav>
<motion.div
className="grid grid-cols-1 gap-10 lg:grid-cols-12 lg:items-start"
variants={fadeUp}>
<div className="lg:col-span-7">
<div className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-background/30 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.22em] text-muted-foreground backdrop-blur-sm">
<Sparkles className="size-3.5" />
Objective Deep Dive
</div>
<h1 className="mt-4 text-balance text-5xl font-semibold tracking-tight text-foreground [font-family:var(--font-display)] sm:text-6xl">
{objective.name}
</h1>
<p className="mt-4 max-w-2xl text-pretty text-base leading-relaxed text-muted-foreground sm:text-lg">
{objective.description} Explore why this setup is recommended, compare options, and
tune it to your constraints before you start in Roo Code Cloud.
</p>
<div className="mt-7 flex flex-wrap items-center gap-3">
<Link
href={
primaryRoleId
? `/evals/recommendations/roles/${primaryRoleId}${roleQuery}`
: "/evals/recommendations"
}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/20 px-5 py-2.5 text-sm font-semibold text-foreground transition-colors hover:bg-background/30">
<BarChart3 className="size-4" />
See model data
</Link>
<Link
href={
primaryRoleId
? `/evals/recommendations/roles/${primaryRoleId}/compare${roleQuery}`
: "/evals/recommendations"
}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/10 px-5 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-background/20 hover:text-foreground">
<Workflow className="size-4" />
Compare candidates
</Link>
</div>
</div>
<div className="lg:col-span-5">
<div className="relative overflow-hidden rounded-2xl border border-border/50 bg-background/25 p-6 backdrop-blur-xl">
<div
aria-hidden="true"
className="pointer-events-none absolute -right-40 -top-40 size-96 rounded-full bg-foreground/[0.04] blur-3xl"
/>
<div className="relative">
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground/70">
Configuration
</p>
<span className="rounded-full border border-border/50 bg-background/25 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-wider text-foreground/70">
<SlidersHorizontal className="mr-1 inline-block size-3" />
Tunable
</span>
</div>
<div className="mt-4">
<p className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Optimized for
</p>
<div className="mt-2 inline-flex rounded-full border border-border/50 bg-card/30 p-1 backdrop-blur-sm">
{modePill.map((m) => {
const selected = m.id === mode
return (
<button
key={m.id}
type="button"
aria-pressed={selected}
onClick={() => onSetMode(m.id)}
className={[
"rounded-full px-3 py-1.5 text-xs font-semibold transition-colors",
selected
? "bg-foreground/10 text-foreground"
: "text-muted-foreground hover:text-foreground",
].join(" ")}>
{m.label}
</button>
)
})}
</div>
</div>
<div className="mt-5 rounded-2xl border border-border/50 bg-background/10 p-4">
<p className="text-xs font-semibold text-foreground">Primary model</p>
<p className="mt-1 text-sm text-muted-foreground">
{primaryCandidate ? (
<>
<span className="font-semibold text-foreground">
{primaryCandidate.displayName}
</span>{" "}
<span className="text-muted-foreground/70">
({shortProvider(primaryCandidate.provider)})
</span>
</>
) : (
<span className="text-muted-foreground">
Pick an objective to see recommendations.
</span>
)}
</p>
{primaryCandidate ? (
<div className="mt-3 grid grid-cols-3 gap-2">
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Score
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{primaryCandidate.compositeScore}
</p>
</div>
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Success
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{primaryCandidate.successRate}%
</p>
</div>
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Daily
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{formatDollars(primaryCandidate.estimatedDailyCost)}
</p>
</div>
</div>
) : null}
</div>
<div className="mt-4 grid grid-cols-1 gap-3 sm:grid-cols-2">
<a
href={cloudHref}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-foreground/20 bg-foreground/10 px-4 py-3 text-sm font-semibold text-foreground transition-colors hover:bg-foreground/15">
Open in Roo Code Cloud
<ExternalLink className="size-4" />
</a>
<button
type="button"
onClick={copyPrompt}
disabled={!examplePrompt}
className="inline-flex items-center justify-center gap-2 rounded-2xl border border-border/60 bg-background/10 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:border-border hover:bg-background/15 hover:text-foreground disabled:cursor-not-allowed disabled:opacity-60">
Copy example prompt
<Copy className="size-4" />
</button>
</div>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">
This is a starting point. Adjust the lineup and model picks to match your
repo and delivery constraints.
</p>
</div>
</div>
</div>
</motion.div>
</motion.div>
</div>
</section>
<section className="relative pb-20 pt-14">
<div className="container relative z-10 mx-auto max-w-screen-xl px-4 sm:px-6 lg:px-8">
<div className="grid grid-cols-1 gap-10 lg:grid-cols-12 lg:items-start">
<div className="lg:col-span-7">
<h2 className="text-2xl font-semibold tracking-tight text-foreground [font-family:var(--font-display)]">
Recommended lineup
</h2>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
For {objective.name}, this lineup balances momentum and safety. Swap models per agent if
you have different constraints.
</p>
<div className="mt-6 space-y-4">
{lineup.map((roleId) => {
const rec = recByRole.get(roleId)
if (!rec) return null
const selected = selectedByRole.get(roleId) ?? null
return (
<div
key={roleId}
className="rounded-2xl border border-border/50 bg-background/15 p-5 backdrop-blur-sm">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-muted-foreground/70">
For
</p>
<p className="mt-2 text-lg font-semibold tracking-tight text-foreground">
{rec.role.name}
</p>
<p className="mt-1 text-sm text-muted-foreground">
{rec.role.salaryRange}
</p>
</div>
<div className="flex flex-wrap gap-2">
<Link
href={`/evals/recommendations/roles/${roleId}${roleQuery}`}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/10 px-4 py-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-background/20 hover:text-foreground">
Details
<ArrowRight className="size-3.5" />
</Link>
<Link
href={`/evals/recommendations/roles/${roleId}/compare${roleQuery}`}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/10 px-4 py-2 text-xs font-semibold text-muted-foreground transition-colors hover:bg-background/20 hover:text-foreground">
Compare
<ArrowRight className="size-3.5" />
</Link>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-5 sm:items-end">
<div className="sm:col-span-3">
<label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground/70">
Model pick
</label>
<div className="mt-2">
<select
value={selected?.modelId ?? ""}
onChange={(e) => onSelectModel(roleId, e.target.value)}
className="w-full rounded-xl border border-border/60 bg-background/20 px-3 py-2.5 text-sm font-semibold text-foreground shadow-sm outline-none transition-colors focus:border-foreground/30">
{rec.allCandidates.slice(0, 12).map((c) => (
<option key={c.modelId} value={c.modelId}>
{c.displayName} · {shortProvider(c.provider)} ·{" "}
{c.compositeScore}
</option>
))}
</select>
</div>
</div>
<div className="sm:col-span-2">
<div className="grid grid-cols-3 gap-2">
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Success
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{selected ? `${selected.successRate}%` : "—"}
</p>
</div>
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Daily
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{selected
? formatDollars(selected.estimatedDailyCost)
: "—"}
</p>
</div>
<div className="rounded-xl border border-border/50 bg-background/10 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/70">
Time
</p>
<p className="mt-1 font-mono text-sm font-semibold text-foreground tabular-nums">
{selected
? formatSeconds(selected.avgTimePerTask)
: "—"}
</p>
</div>
</div>
</div>
</div>
</div>
)
})}
</div>
</div>
<div className="lg:col-span-5">
<div className="rounded-2xl border border-border/50 bg-background/15 p-6 backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground/70">
Why this works
</p>
<ul className="mt-4 space-y-2 text-sm leading-relaxed text-muted-foreground">
{(objective.whyItWorks ?? []).slice(0, 4).map((line) => (
<li key={line}>- {line}</li>
))}
</ul>
{objective.builderProfile?.howItWorks?.length ? (
<>
<p className="mt-6 text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground/70">
How it runs
</p>
<ol className="mt-4 space-y-2 text-sm leading-relaxed text-muted-foreground">
{objective.builderProfile.howItWorks.slice(0, 4).map((step, idx) => (
<li key={`${idx}:${step}`} className="flex gap-3">
<span className="mt-0.5 inline-flex size-6 shrink-0 items-center justify-center rounded-lg border border-border/60 bg-background/20 font-mono text-xs font-semibold text-foreground/80">
{idx + 1}
</span>
<span>{step}</span>
</li>
))}
</ol>
</>
) : null}
{examplePrompt ? (
<div className="mt-6 rounded-2xl border border-border/50 bg-background/10 p-4">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Example prompt
</p>
<pre className="mt-3 max-h-56 overflow-auto whitespace-pre-wrap rounded-xl border border-border/50 bg-background/10 p-3 text-xs leading-relaxed text-foreground/85">
{examplePrompt}
</pre>
</div>
) : null}
</div>
<div className="mt-6 rounded-2xl border border-border/50 bg-background/15 p-6 backdrop-blur-sm">
<p className="text-xs font-semibold uppercase tracking-[0.24em] text-muted-foreground/70">
Data preview
</p>
<p className="mt-2 text-sm leading-relaxed text-muted-foreground">
Composite score vs estimated daily cost for the primary agent, with dot size mapped
to success rate.
</p>
<div className="mt-4 h-[320px] w-full overflow-hidden rounded-2xl border border-border/50 bg-background/10 p-3">
<ResponsiveContainer width="100%" height="100%">
<ScatterChart margin={{ top: 10, right: 10, bottom: 10, left: 0 }}>
<XAxis
type="number"
dataKey="dailyCost"
name="Daily Cost"
tick={{ fill: "currentColor", fontSize: 12 }}
axisLine={{ stroke: "rgba(255,255,255,0.08)" }}
tickLine={{ stroke: "rgba(255,255,255,0.08)" }}
/>
<YAxis
type="number"
dataKey="score"
name="Score"
domain={[40, 100]}
tick={{ fill: "currentColor", fontSize: 12 }}
axisLine={{ stroke: "rgba(255,255,255,0.08)" }}
tickLine={{ stroke: "rgba(255,255,255,0.08)" }}
/>
<ZAxis type="number" dataKey="dotSize" range={[60, 360]} />
<Tooltip content={<ObjectiveTooltip />} />
<Scatter data={primaryScatter} fill="#3b82f6">
{primaryScatter.map((d, i) => (
<Cell
key={i}
fill={d.isSelected ? "#22c55e" : "#3b82f6"}
stroke={
d.isSelected
? "rgba(34,197,94,0.65)"
: "rgba(59,130,246,0.35)"
}
strokeWidth={d.isSelected ? 2 : 1}
/>
))}
</Scatter>
</ScatterChart>
</ResponsiveContainer>
</div>
<div className="mt-4">
<Link
href={
primaryRoleId
? `/evals/recommendations/roles/${primaryRoleId}/compare${roleQuery}`
: "/evals/recommendations"
}
className="inline-flex items-center gap-2 rounded-full border border-border/60 bg-background/10 px-5 py-2.5 text-sm font-semibold text-muted-foreground transition-colors hover:bg-background/20 hover:text-foreground">
Compare the full set
<ArrowRight className="size-4" />
</Link>
</div>
</div>
</div>
</div>
</div>
</section>
</div>
)
}

View file

@ -0,0 +1,102 @@
import type { Metadata } from "next"
import { notFound } from "next/navigation"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getRoleRecommendation } from "@/lib/mock-recommendations"
import { getEvalOutcomeBySlug } from "@/lib/eval-outcomes"
import { ObjectiveContent } from "./objective-content"
type PageProps = {
params: Promise<{ objectiveSlug: string }>
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
function isMode(value: string): value is "best" | "fastest" | "cost" {
return value === "best" || value === "fastest" || value === "cost"
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { objectiveSlug } = await params
const objective = getEvalOutcomeBySlug(objectiveSlug)
if (!objective) {
return {
title: "Objective Not Found | Roo Code Evals",
description: "The requested objective was not found.",
}
}
const title = `${objective.name} — Build Profile | Roo Code Evals`
const description = `Investigate the recommended lineup for ${objective.name}. Compare options, explore data, and start in Roo Code Cloud.`
const ogDescription = `${objective.name} — Build Profile`
const path = `/evals/recommendations/${objective.slug}`
return {
title,
description,
alternates: { canonical: `${SEO.url}${path}` },
openGraph: {
title,
description,
url: `${SEO.url}${path}`,
siteName: SEO.name,
images: [
{
url: ogImageUrl(title, ogDescription),
width: 1200,
height: 630,
alt: title,
},
],
locale: SEO.locale,
type: "website",
},
twitter: {
card: SEO.twitterCard,
title,
description,
images: [ogImageUrl(title, ogDescription)],
},
keywords: [...SEO.keywords, "AI coding", "model recommendations", "coding evals", "roo code cloud"],
}
}
export default async function ObjectiveDeepDivePage({ params, searchParams }: PageProps) {
const { objectiveSlug } = await params
const objective = getEvalOutcomeBySlug(objectiveSlug)
if (!objective) notFound()
const sp = (await searchParams) ?? {}
const modeRaw = typeof sp.mode === "string" ? sp.mode : undefined
const initialMode = modeRaw && isMode(modeRaw) ? modeRaw : "best"
const recs: Array<{ roleId: string; recommendation: NonNullable<ReturnType<typeof getRoleRecommendation>> }> = []
for (const roleId of objective.recommendedRoleIds) {
const recommendation = getRoleRecommendation(roleId)
if (recommendation) recs.push({ roleId, recommendation })
}
return (
<ObjectiveContent
objective={{
id: objective.id,
slug: objective.slug,
name: objective.name,
description: objective.description,
whyItWorks: objective.whyItWorks,
recommendedRoleIds: objective.recommendedRoleIds,
builderProfile: objective.builderProfile
? {
title: objective.builderProfile.title,
description: objective.builderProfile.description,
examplePrompt: objective.builderProfile.examplePrompt,
howItWorks: objective.builderProfile.howItWorks,
}
: undefined,
}}
initialMode={initialMode}
recs={recs}
/>
)
}

View file

@ -1,2 +0,0 @@
export { generateMetadata } from "../../../workers/[roleId]/compare/page"
export { default } from "../../../workers/[roleId]/compare/page"

View file

@ -1,2 +0,0 @@
export { generateMetadata } from "../../workers/[roleId]/page"
export { default } from "../../workers/[roleId]/page"

View file

@ -0,0 +1,9 @@
import type { ReactNode } from "react"
import { Fraunces, IBM_Plex_Sans } from "next/font/google"
const display = Fraunces({ subsets: ["latin"], variable: "--font-display" })
const body = IBM_Plex_Sans({ subsets: ["latin"], weight: ["400", "500", "600"], variable: "--font-body" })
export default function RecommendationsLayout({ children }: { children: ReactNode }) {
return <div className={`${body.variable} ${display.variable} [font-family:var(--font-body)]`}>{children}</div>
}

View file

@ -1,9 +1,10 @@
import type { Metadata } from "next"
import { Fraunces, IBM_Plex_Sans } from "next/font/google"
import { redirect } from "next/navigation"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getEngineerRoles, getAllRecommendations } from "@/lib/mock-recommendations"
import { EVAL_OUTCOMES, isEvalOutcomeId } from "@/lib/eval-outcomes"
import { WorkersContent } from "../workers/workers-content"
@ -15,9 +16,6 @@ const DESCRIPTION =
const OG_DESCRIPTION = "Outcome-first recommendations for shipping production code"
const PATH = "/evals/recommendations"
const display = Fraunces({ subsets: ["latin"], variable: "--font-display" })
const body = IBM_Plex_Sans({ subsets: ["latin"], weight: ["400", "500", "600"], variable: "--font-body" })
export const metadata: Metadata = {
title: TITLE,
description: DESCRIPTION,
@ -61,7 +59,25 @@ export const metadata: Metadata = {
// ── Page Component ──────────────────────────────────────────────────────────
export default function RecommendationsPage() {
type PageProps = {
searchParams?: Promise<Record<string, string | string[] | undefined>>
}
export default async function RecommendationsPage({ searchParams }: PageProps) {
const sp = (await searchParams) ?? {}
const view = typeof sp.view === "string" ? sp.view : undefined
const outcome = typeof sp.outcome === "string" ? sp.outcome : undefined
const mode = typeof sp.mode === "string" ? sp.mode : undefined
// Legacy deep link: move profile investigations into the dedicated objective pages.
if (view === "profile" && outcome && isEvalOutcomeId(outcome)) {
const objective = EVAL_OUTCOMES.find((o) => o.id === outcome)
if (objective) {
const qs = mode ? `?mode=${encodeURIComponent(mode)}` : ""
redirect(`/evals/recommendations/${objective.slug}${qs}`)
}
}
const roles = getEngineerRoles()
const recommendations = getAllRecommendations()
@ -79,16 +95,15 @@ export default function RecommendationsPage() {
.pop()
return (
<div className={`${body.variable} ${display.variable} [font-family:var(--font-body)]`}>
<WorkersContent
roles={roles}
recommendations={recommendations}
totalEvalRuns={totalEvalRuns}
totalExercises={totalExercises}
totalModels={totalModels}
lastUpdated={lastUpdated}
workersRootPath="/evals/recommendations"
/>
</div>
<WorkersContent
roles={roles}
recommendations={recommendations}
totalEvalRuns={totalEvalRuns}
totalExercises={totalExercises}
totalModels={totalModels}
lastUpdated={lastUpdated}
workersRootPath="/evals/recommendations"
roleBasePath="/evals/recommendations/roles"
/>
)
}

View file

@ -0,0 +1,85 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getEngineerRole, getRoleRecommendation } from "@/lib/mock-recommendations"
import { ComparisonChart } from "../../../../workers/[roleId]/compare/comparison-chart"
type PageProps = { params: Promise<{ roleId: string }> }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { roleId } = await params
const role = getEngineerRole(roleId)
if (!role) {
return {
title: "Role Not Found | Roo Code Evals",
description: "The requested engineer role was not found.",
}
}
const title = `Compare Models — ${role.name} | Roo Code Evals`
const description = `Interactive comparison of AI models for the ${role.name} setup. Compare composite score, success rate, cost efficiency, and speed.`
const ogDescription = `Compare Models — ${role.name}`
const path = `/evals/recommendations/roles/${roleId}/compare`
return {
title,
description,
alternates: {
canonical: `${SEO.url}${path}`,
},
openGraph: {
title,
description,
url: `${SEO.url}${path}`,
siteName: SEO.name,
images: [
{
url: ogImageUrl(title, ogDescription),
width: 1200,
height: 630,
alt: title,
},
],
locale: SEO.locale,
type: "website",
},
twitter: {
card: SEO.twitterCard,
title,
description,
images: [ogImageUrl(title, ogDescription)],
},
keywords: [
...SEO.keywords,
"AI coding",
"model comparison",
"coding evals",
role.name.toLowerCase(),
"bar chart",
"model comparison",
],
}
}
export default async function RecommendationComparePage({ params }: PageProps) {
const { roleId } = await params
const recommendation = getRoleRecommendation(roleId)
if (!recommendation) {
notFound()
}
return (
<ComparisonChart
recommendation={recommendation}
role={recommendation.role}
roleId={roleId}
workersRootPath="/evals/recommendations"
roleBasePath="/evals/recommendations/roles"
/>
)
}

View file

@ -0,0 +1,101 @@
import { notFound } from "next/navigation"
import type { Metadata } from "next"
import { SEO } from "@/lib/seo"
import { ogImageUrl } from "@/lib/og"
import { getRoleRecommendation, getCloudSetupUrl } from "@/lib/mock-recommendations"
import { CandidatesContent } from "../../../workers/[roleId]/candidates-content"
type PageProps = { params: Promise<{ roleId: string }> }
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { roleId } = await params
const recommendation = getRoleRecommendation(roleId)
if (!recommendation) {
return {
title: "Role Not Found | Roo Code Evals",
description: "The requested engineer role was not found.",
}
}
const { role } = recommendation
const title = `${role.name} — Recommended Models | Roo Code Evals`
const description = `Eval-backed recommendations for ${role.name}. Compare models by success rate, cost, and speed across 5 languages.`
const ogDescription = `${role.name} — Recommended Models`
const path = `/evals/recommendations/roles/${roleId}`
return {
title,
description,
alternates: {
canonical: `${SEO.url}${path}`,
},
openGraph: {
title,
description,
url: `${SEO.url}${path}`,
siteName: SEO.name,
images: [
{
url: ogImageUrl(title, ogDescription),
width: 1200,
height: 630,
alt: title,
},
],
locale: SEO.locale,
type: "website",
},
twitter: {
card: SEO.twitterCard,
title,
description,
images: [ogImageUrl(title, ogDescription)],
},
keywords: [
...SEO.keywords,
"AI coding",
"coding agents",
"model recommendations",
"coding evals",
role.name.toLowerCase(),
"model comparison",
],
}
}
export default async function RecommendationRolePage({ params }: PageProps) {
const { roleId } = await params
const recommendation = getRoleRecommendation(roleId)
if (!recommendation) {
notFound()
}
const { role, best, budgetHire, speedHire, allCandidates, totalEvalRuns, totalExercises, lastUpdated } =
recommendation
const cloudUrls: Record<string, string> = {}
for (const candidate of allCandidates) {
cloudUrls[candidate.modelId] = getCloudSetupUrl(candidate)
}
return (
<CandidatesContent
roleId={roleId}
role={role}
best={best}
budgetHire={budgetHire}
speedHire={speedHire}
allCandidates={allCandidates}
totalEvalRuns={totalEvalRuns}
totalExercises={totalExercises}
lastUpdated={lastUpdated}
cloudUrls={cloudUrls}
workersRootPath="/evals/recommendations"
roleBasePath="/evals/recommendations/roles"
/>
)
}

View file

@ -645,6 +645,11 @@ export type CandidatesContentProps = {
lastUpdated: string
cloudUrls: Record<string, string>
workersRootPath?: string
/**
* Base path for role detail routes, without the role id.
* Examples: `/evals/workers`, `/evals/recommendations/roles`.
*/
roleBasePath?: string
}
// ── Main Content Component ──────────────────────────────────────────────────
@ -661,19 +666,27 @@ export function CandidatesContent({
lastUpdated,
cloudUrls,
workersRootPath = "/evals/recommendations",
roleBasePath = workersRootPath,
}: CandidatesContentProps) {
const searchParams = useSearchParams()
const theme = ROLE_THEMES[roleId] ?? DEFAULT_THEME
const IconComponent = ICON_MAP[role.icon] ?? Code
const outcome = searchParams.get("outcome")
const objectiveSlug = searchParams.get("objective")
const mode = searchParams.get("mode")
const setupQuery = (() => {
const outcome = searchParams.get("outcome")
if (!outcome) return ""
if (!outcome && !objectiveSlug) return ""
const params = new URLSearchParams()
params.set("outcome", outcome)
const mode = searchParams.get("mode")
if (outcome) params.set("outcome", outcome)
if (objectiveSlug) params.set("objective", objectiveSlug)
if (mode) params.set("mode", mode)
return `?${params.toString()}`
})()
let homeHref = `${workersRootPath}${setupQuery}`
if (objectiveSlug && workersRootPath === "/evals/recommendations") {
homeHref = `${workersRootPath}/${objectiveSlug}`
if (mode) homeHref += `?mode=${encodeURIComponent(mode)}`
}
return (
<>
@ -706,9 +719,7 @@ export function CandidatesContent({
Evals
</Link>
<span className="text-border">/</span>
<Link
href={`${workersRootPath}${setupQuery}`}
className="transition-colors hover:text-foreground">
<Link href={homeHref} className="transition-colors hover:text-foreground">
Build with Roo Code Cloud
</Link>
<span className="text-border">/</span>
@ -977,7 +988,7 @@ export function CandidatesContent({
{/* Compare link */}
<motion.div className="mt-6 text-center" variants={fadeUpVariants}>
<Link
href={`${workersRootPath}/${roleId}/compare${setupQuery}`}
href={`${roleBasePath}/${roleId}/compare${setupQuery}`}
className={`group inline-flex items-center gap-2 rounded-full border ${theme.methodologyBorder} bg-card/50 px-5 py-2.5 text-sm font-medium text-muted-foreground backdrop-blur-sm transition-all duration-300 hover:text-foreground`}>
📊 Compare all candidates
<ArrowRight className="size-4 transition-transform duration-200 group-hover:translate-x-0.5" />
@ -998,7 +1009,7 @@ export function CandidatesContent({
variants={containerVariants}>
<motion.div variants={fadeUpVariants}>
<Link
href={`${workersRootPath}${setupQuery}`}
href={homeHref}
className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-card/50 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-border hover:text-foreground">
<ArrowLeft className="size-4" />
Back to all roles
@ -1006,7 +1017,7 @@ export function CandidatesContent({
</motion.div>
<motion.div className="flex flex-wrap gap-3" variants={fadeUpVariants}>
<Link
href={`${workersRootPath}/${roleId}/compare${setupQuery}`}
href={`${roleBasePath}/${roleId}/compare${setupQuery}`}
className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-card/50 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-border hover:text-foreground">
📊 Compare candidates
</Link>

View file

@ -435,6 +435,11 @@ interface ComparisonChartProps {
role: EngineerRole
roleId: string
workersRootPath?: string
/**
* Base path for role detail routes, without the role id.
* Examples: `/evals/workers`, `/evals/recommendations/roles`.
*/
roleBasePath?: string
}
export function ComparisonChart({
@ -442,19 +447,27 @@ export function ComparisonChart({
role,
roleId,
workersRootPath = "/evals/recommendations",
roleBasePath = workersRootPath,
}: ComparisonChartProps) {
const searchParams = useSearchParams()
const { allCandidates } = recommendation
const theme = ROLE_THEMES[roleId] ?? DEFAULT_THEME
const outcome = searchParams.get("outcome")
const objectiveSlug = searchParams.get("objective")
const mode = searchParams.get("mode")
const setupQuery = (() => {
const outcome = searchParams.get("outcome")
if (!outcome) return ""
if (!outcome && !objectiveSlug) return ""
const params = new URLSearchParams()
params.set("outcome", outcome)
const mode = searchParams.get("mode")
if (outcome) params.set("outcome", outcome)
if (objectiveSlug) params.set("objective", objectiveSlug)
if (mode) params.set("mode", mode)
return `?${params.toString()}`
})()
let homeHref = `${workersRootPath}${setupQuery}`
if (objectiveSlug && workersRootPath === "/evals/recommendations") {
homeHref = `${workersRootPath}/${objectiveSlug}`
if (mode) homeHref += `?mode=${encodeURIComponent(mode)}`
}
// ── State ───────────────────────────────────────────────────────────────
const [selectedLanguage, setSelectedLanguage] = useState<keyof LanguageScores | "all">("all")
@ -578,14 +591,12 @@ export function ComparisonChart({
Evals
</Link>
<span className="text-border">/</span>
<Link
href={`${workersRootPath}${setupQuery}`}
className="transition-colors hover:text-foreground">
<Link href={homeHref} className="transition-colors hover:text-foreground">
Build with Roo Code Cloud
</Link>
<span className="text-border">/</span>
<Link
href={`${workersRootPath}/${roleId}${setupQuery}`}
href={`${roleBasePath}/${roleId}${setupQuery}`}
className="transition-colors hover:text-foreground">
{role.name}
</Link>
@ -1014,7 +1025,7 @@ export function ComparisonChart({
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<Link
href={`${workersRootPath}/${roleId}${setupQuery}`}
href={`${roleBasePath}/${roleId}${setupQuery}`}
className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-card/50 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-border hover:text-foreground">
<ArrowLeft className="size-4" />
Back to {role.name} models
@ -1022,7 +1033,7 @@ export function ComparisonChart({
</div>
<div className="flex flex-wrap gap-3">
<Link
href={`${workersRootPath}${setupQuery}`}
href={homeHref}
className="inline-flex items-center gap-2 rounded-full border border-border/50 bg-card/50 px-4 py-2 text-sm font-medium text-muted-foreground backdrop-blur-sm transition-all duration-200 hover:border-border hover:text-foreground">
<ArrowLeft className="size-3.5" />
All roles

View file

@ -25,7 +25,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const title = `Compare Models — ${role.name} | Roo Code Evals`
const description = `Interactive comparison of AI models for the ${role.name} setup. Compare composite score, success rate, cost efficiency, and speed.`
const ogDescription = `Compare Models — ${role.name}`
const path = `/evals/recommendations/${roleId}/compare`
const path = `/evals/workers/${roleId}/compare`
return {
title,
@ -82,7 +82,7 @@ export default async function CompareModelsPage({ params }: PageProps) {
recommendation={recommendation}
role={recommendation.role}
roleId={roleId}
workersRootPath="/evals/recommendations"
workersRootPath="/evals/workers"
/>
)
}

View file

@ -26,7 +26,7 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const title = `${role.name} — Recommended Models | Roo Code Evals`
const description = `Eval-backed recommendations for ${role.name}. Compare models by success rate, cost, and speed across 5 languages.`
const ogDescription = `${role.name} — Recommended Models`
const path = `/evals/recommendations/${roleId}`
const path = `/evals/workers/${roleId}`
return {
title,
@ -100,7 +100,7 @@ export default async function RoleCandidatesPage({ params }: PageProps) {
totalExercises={totalExercises}
lastUpdated={lastUpdated}
cloudUrls={cloudUrls}
workersRootPath="/evals/recommendations"
workersRootPath="/evals/workers"
/>
)
}

View file

@ -190,6 +190,11 @@ type WorkersContentProps = {
totalModels: number
lastUpdated: string | undefined
workersRootPath?: string
/**
* Base path for role detail routes, without the role id.
* Examples: `/evals/workers`, `/evals/recommendations/roles`.
*/
roleBasePath?: string
}
// Outcomes-first is canonical. Baseline/V1 is removed from the UI.
@ -203,6 +208,7 @@ export function WorkersContent({
totalModels: _totalModels,
lastUpdated,
workersRootPath = "/evals/recommendations",
roleBasePath = workersRootPath,
}: WorkersContentProps) {
const enableOutcomeLayer = ENABLE_OUTCOME_LAYER
const router = useRouter()
@ -272,15 +278,6 @@ export function WorkersContent({
return query ? `?${query}` : ""
}, [effectiveOutcomeId, selectedMode])
const profileViewQuery = useMemo(() => {
if (!effectiveOutcomeId) return ""
const params = new URLSearchParams()
params.set("outcome", effectiveOutcomeId)
params.set("mode", selectedMode)
params.set("view", "profile")
return `?${params.toString()}`
}, [effectiveOutcomeId, selectedMode])
const isProfileView = useMemo(() => {
return searchParams.get("view") === "profile"
}, [searchParams])
@ -593,7 +590,7 @@ export function WorkersContent({
return (
<Link
key={capability.id}
href={`${workersRootPath}/${roleId}${setupQuery}`}
href={`${roleBasePath}/${roleId}${setupQuery}`}
className="group flex items-start justify-between gap-3 rounded-xl border border-border/50 bg-background/10 px-4 py-3 transition-colors hover:bg-background/20">
<div className="min-w-0">
<p className="text-sm font-semibold text-foreground">
@ -890,7 +887,7 @@ export function WorkersContent({
return (
<Link
key={capability.id}
href={`${workersRootPath}/${roleId}${setupQuery}`}
href={`${roleBasePath}/${roleId}${setupQuery}`}
className="group relative px-4 py-3 pl-5 transition-colors hover:bg-background/25">
<span
aria-hidden
@ -959,7 +956,7 @@ export function WorkersContent({
<ArrowRight className="size-4" />
</Link>
<Link
href={`${workersRootPath}${profileViewQuery}#outcomes`}
href={`/evals/recommendations/${selectedOutcome.slug}?mode=${selectedMode}`}
className="inline-flex flex-1 items-center justify-center gap-2 rounded-2xl border border-border/60 bg-background/10 px-4 py-3 text-sm font-semibold text-muted-foreground transition-colors hover:border-border hover:bg-background/15 hover:text-foreground">
Learn more / customize
<ArrowRight className="size-4" />

View file

@ -34,6 +34,8 @@ export type EvalOutcomeProfile = {
export type EvalOutcome = {
id: EvalOutcomeId
/** URL slug used for objective deep dives: /evals/recommendations/<slug> */
slug: string
name: string
description: string
icon: LucideIcon
@ -53,6 +55,7 @@ export type EvalOutcome = {
export const EVAL_OUTCOMES: EvalOutcome[] = [
{
id: "review_guardrails",
slug: "idea-prototype",
name: "Idea → Prototype",
description: "Turn a vague idea into a working demo in your real codebase.",
icon: Sparkles,
@ -116,6 +119,7 @@ Deliver:
},
{
id: "prototype_to_pr",
slug: "prototype-pr",
name: "Prototype → PR",
description: "Build a working prototype on the production codebase, then turn it into a reviewable diff.",
icon: Sparkles,
@ -188,6 +192,7 @@ Deliver:
},
{
id: "issue_to_pr",
slug: "issue-pr",
name: "Issue → PR",
description: "Run end-to-end work in the background and come back to a reviewable result.",
icon: GitPullRequest,
@ -249,6 +254,7 @@ Deliver:
},
{
id: "sentry_triage",
slug: "customer-escalation-resolved",
name: "Customer Escalation → Resolved",
description: "Triage a customer-blocking issue and ship the smallest safe fix.",
icon: Bug,
@ -318,6 +324,7 @@ Deliver:
},
{
id: "repro_to_fix",
slug: "bug-report-fix",
name: "Bug Report → Fix",
description: "Reproduce, isolate, patch, and validate in one loop.",
icon: Workflow,
@ -381,6 +388,7 @@ Deliver:
},
{
id: "paper_cuts",
slug: "paper-cuts-shipped",
name: "Paper Cuts → Shipped",
description: "Fix the small stuff without dragging engineers off big projects.",
icon: CheckCircle2,
@ -447,3 +455,7 @@ Deliver:
export function isEvalOutcomeId(value: string): value is EvalOutcomeId {
return EVAL_OUTCOMES.some((o) => o.id === value)
}
export function getEvalOutcomeBySlug(slug: string): EvalOutcome | undefined {
return EVAL_OUTCOMES.find((o) => o.slug === slug)
}