feat(web-evals): enhance dashboard with dynamic tool columns and UX improvements (#9592)

Co-authored-by: Roo Code <roomote@roocode.com>
This commit is contained in:
Hannes Rudolph 2025-11-25 22:15:29 -07:00 committed by GitHub
parent 3c989d3591
commit 4442397507
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 727 additions and 62 deletions

View file

@ -5,15 +5,36 @@ import { LoaderCircle } from "lucide-react"
import type { Run, TaskMetrics as _TaskMetrics } from "@roo-code/evals"
import { formatCurrency, formatDuration, formatTokens } from "@/lib/formatters"
import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters"
import { useRunStatus } from "@/hooks/use-run-status"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui"
import { TaskStatus } from "./task-status"
import { RunStatus } from "./run-status"
type TaskMetrics = Pick<_TaskMetrics, "tokensIn" | "tokensOut" | "tokensContext" | "duration" | "cost">
type ToolUsageEntry = { attempts: number; failures: number }
type ToolUsage = Record<string, ToolUsageEntry>
// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN")
function getToolAbbreviation(toolName: string): string {
return toolName
.split("_")
.map((word) => word[0]?.toUpperCase() ?? "")
.join("")
}
export function Run({ run }: { run: Run }) {
const runStatus = useRunStatus(run)
const { tasks, tokenUsage, usageUpdatedAt } = runStatus
@ -41,16 +62,170 @@ export function Run({ run }: { run: Run }) {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks, tokenUsage, usageUpdatedAt])
// Compute aggregate stats
const stats = useMemo(() => {
if (!tasks) return null
const passed = tasks.filter((t) => t.passed === true).length
const failed = tasks.filter((t) => t.passed === false).length
// Count running tasks exactly like TaskStatus shows spinner:
// - passed is not true and not false (null/undefined)
// - AND has activity (startedAt or tokenUsage)
const running = tasks.filter(
(t) => t.passed !== true && t.passed !== false && (t.startedAt || tokenUsage.get(t.id)),
).length
const pending = tasks.filter(
(t) => t.passed !== true && t.passed !== false && !t.startedAt && !tokenUsage.get(t.id),
).length
const total = tasks.length
const completed = passed + failed
let totalTokensIn = 0
let totalTokensOut = 0
let totalCost = 0
let totalDuration = 0
// Aggregate tool usage from completed tasks
const toolUsage: ToolUsage = {}
for (const task of tasks) {
const metrics = taskMetrics[task.id]
if (metrics) {
totalTokensIn += metrics.tokensIn
totalTokensOut += metrics.tokensOut
totalCost += metrics.cost
totalDuration += metrics.duration
}
// Aggregate tool usage from finished tasks with taskMetrics
if (task.finishedAt && task.taskMetrics?.toolUsage) {
for (const [key, usage] of Object.entries(task.taskMetrics.toolUsage)) {
const tool = key as keyof ToolUsage
if (!toolUsage[tool]) {
toolUsage[tool] = { attempts: 0, failures: 0 }
}
toolUsage[tool].attempts += usage.attempts
toolUsage[tool].failures += usage.failures
}
}
}
return {
passed,
failed,
running,
pending,
total,
completed,
passRate: completed > 0 ? ((passed / completed) * 100).toFixed(1) : null,
totalTokensIn,
totalTokensOut,
totalCost,
totalDuration,
toolUsage,
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tasks, taskMetrics, tokenUsage, usageUpdatedAt])
return (
<>
<div>
<div className="mb-2">
<div className="mb-4">
<div>
<div className="font-mono">{run.model}</div>
{run.description && <div className="text-sm text-muted-foreground">{run.description}</div>}
</div>
{!run.taskMetricsId && <RunStatus runStatus={runStatus} />}
</div>
{stats && (
<div className="mb-4 p-4 border rounded-lg bg-muted/50">
{/* Main Stats Row */}
<div className="flex flex-wrap items-start justify-between gap-x-6 gap-y-3">
{/* Passed/Failed */}
<div className="text-center">
<div className="text-2xl font-bold whitespace-nowrap">
<span className="text-green-600">{stats.passed}</span>
<span className="text-muted-foreground mx-1">/</span>
<span className="text-red-600">{stats.failed}</span>
{stats.running > 0 && (
<span className="text-yellow-600 text-sm ml-2">({stats.running})</span>
)}
</div>
<div className="text-xs text-muted-foreground">Passed / Failed</div>
</div>
{/* Pass Rate */}
<div className="text-center">
<div className="text-2xl font-bold">{stats.passRate ? `${stats.passRate}%` : "-"}</div>
<div className="text-xs text-muted-foreground">Pass Rate</div>
</div>
{/* Tokens */}
<div className="text-center">
<div className="text-xl font-bold font-mono whitespace-nowrap">
{formatTokens(stats.totalTokensIn)}
<span className="text-muted-foreground mx-1">/</span>
{formatTokens(stats.totalTokensOut)}
</div>
<div className="text-xs text-muted-foreground">Tokens In / Out</div>
</div>
{/* Cost */}
<div className="text-center">
<div className="text-2xl font-bold font-mono">{formatCurrency(stats.totalCost)}</div>
<div className="text-xs text-muted-foreground">Cost</div>
</div>
{/* Duration */}
<div className="text-center">
<div className="text-2xl font-bold font-mono whitespace-nowrap">
{stats.totalDuration > 0 ? formatDuration(stats.totalDuration) : "-"}
</div>
<div className="text-xs text-muted-foreground">Duration</div>
</div>
{/* Tool Usage - Inline */}
{Object.keys(stats.toolUsage).length > 0 && (
<div className="flex items-center gap-2 flex-wrap">
{Object.entries(stats.toolUsage)
.sort(([, a], [, b]) => b.attempts - a.attempts)
.map(([toolName, usage]) => {
const abbr = getToolAbbreviation(toolName)
const successRate =
usage.attempts > 0
? ((usage.attempts - usage.failures) / usage.attempts) * 100
: 100
const rateColor =
successRate === 100
? "text-green-500"
: successRate >= 80
? "text-yellow-500"
: "text-red-500"
return (
<Tooltip key={toolName}>
<TooltipTrigger asChild>
<div className="flex items-center gap-1 px-2 py-1 rounded bg-background/50 border border-border/50 hover:border-border transition-colors cursor-default text-xs">
<span className="font-medium text-muted-foreground">
{abbr}
</span>
<span className="font-bold tabular-nums">
{usage.attempts}
</span>
<span className={`${rateColor}`}>
{formatToolUsageSuccessRate(usage)}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="bottom">{toolName}</TooltipContent>
</Tooltip>
)
})}
</div>
)}
</div>
</div>
)}
{!tasks ? (
<LoaderCircle className="size-4 animate-spin" />
) : (

View file

@ -1,6 +1,6 @@
"use client"
import { useCallback, useState } from "react"
import { useCallback, useEffect, useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { z } from "zod"
import { useQuery } from "@tanstack/react-query"
@ -9,7 +9,14 @@ import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { X, Rocket, Check, ChevronsUpDown, SlidersHorizontal } from "lucide-react"
import { globalSettingsSchema, providerSettingsSchema, EVALS_SETTINGS, getModelId } from "@roo-code/types"
import {
globalSettingsSchema,
providerSettingsSchema,
EVALS_SETTINGS,
getModelId,
type ProviderSettings,
type GlobalSettings,
} from "@roo-code/types"
import { createRun } from "@/actions/runs"
import { getExercises } from "@/actions/exercises"
@ -59,6 +66,12 @@ import {
import { SettingsDiff } from "./settings-diff"
type ImportedSettings = {
apiConfigs: Record<string, ProviderSettings>
globalSettings: GlobalSettings
currentApiConfigName: string
}
export function NewRun() {
const router = useRouter()
@ -66,6 +79,11 @@ export function NewRun() {
const [modelPopoverOpen, setModelPopoverOpen] = useState(false)
const [useNativeToolProtocol, setUseNativeToolProtocol] = useState(true)
// State for imported settings with config selection
const [importedSettings, setImportedSettings] = useState<ImportedSettings | null>(null)
const [selectedConfigName, setSelectedConfigName] = useState<string>("")
const [configPopoverOpen, setConfigPopoverOpen] = useState(false)
const openRouter = useOpenRouterModels()
const rooCodeCloud = useRooCodeCloudModels()
const models = provider === "openrouter" ? openRouter.data : rooCodeCloud.data
@ -75,6 +93,9 @@ export function NewRun() {
const exercises = useQuery({ queryKey: ["getExercises"], queryFn: () => getExercises() })
// State for selected exercises (needed for language toggle buttons)
const [selectedExercises, setSelectedExercises] = useState<string[]>([])
const form = useForm<CreateRun>({
resolver: zodResolver(createRunSchema),
defaultValues: {
@ -98,6 +119,88 @@ export function NewRun() {
const [model, suite, settings] = watch(["model", "suite", "settings", "concurrency"])
// Load concurrency and timeout from localStorage on mount
useEffect(() => {
const savedConcurrency = localStorage.getItem("evals-concurrency")
if (savedConcurrency) {
const parsed = parseInt(savedConcurrency, 10)
if (!isNaN(parsed) && parsed >= CONCURRENCY_MIN && parsed <= CONCURRENCY_MAX) {
setValue("concurrency", parsed)
}
}
const savedTimeout = localStorage.getItem("evals-timeout")
if (savedTimeout) {
const parsed = parseInt(savedTimeout, 10)
if (!isNaN(parsed) && parsed >= TIMEOUT_MIN && parsed <= TIMEOUT_MAX) {
setValue("timeout", parsed)
}
}
}, [setValue])
// Extract unique languages from exercises
const languages = useMemo(() => {
if (!exercises.data) return []
const langs = new Set<string>()
for (const path of exercises.data) {
const lang = path.split("/")[0]
if (lang) langs.add(lang)
}
return Array.from(langs).sort()
}, [exercises.data])
// Get exercises for a specific language
const getExercisesForLanguage = useCallback(
(lang: string) => {
if (!exercises.data) return []
return exercises.data.filter((path) => path.startsWith(`${lang}/`))
},
[exercises.data],
)
// Toggle all exercises for a language
const toggleLanguage = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
const allSelected = langExercises.every((ex) => selectedExercises.includes(ex))
let newSelected: string[]
if (allSelected) {
// Remove all exercises for this language
newSelected = selectedExercises.filter((ex) => !ex.startsWith(`${lang}/`))
} else {
// Add all exercises for this language (avoiding duplicates)
const existing = new Set(selectedExercises)
for (const ex of langExercises) {
existing.add(ex)
}
newSelected = Array.from(existing)
}
setSelectedExercises(newSelected)
setValue("exercises", newSelected)
},
[getExercisesForLanguage, selectedExercises, setValue],
)
// Check if all exercises for a language are selected
const isLanguageSelected = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
return langExercises.length > 0 && langExercises.every((ex) => selectedExercises.includes(ex))
},
[getExercisesForLanguage, selectedExercises],
)
// Check if some (but not all) exercises for a language are selected
const isLanguagePartiallySelected = useCallback(
(lang: string) => {
const langExercises = getExercisesForLanguage(lang)
const selectedCount = langExercises.filter((ex) => selectedExercises.includes(ex)).length
return selectedCount > 0 && selectedCount < langExercises.length
},
[getExercisesForLanguage, selectedExercises],
)
const onSubmit = useCallback(
async (values: CreateRun) => {
try {
@ -155,8 +258,19 @@ export function NewRun() {
})
.parse(JSON.parse(await file.text()))
const providerSettings = providerProfiles.apiConfigs[providerProfiles.currentApiConfigName] ?? {}
// Store all imported configs for user selection
setImportedSettings({
apiConfigs: providerProfiles.apiConfigs,
globalSettings,
currentApiConfigName: providerProfiles.currentApiConfigName,
})
// Default to the current config
const defaultConfigName = providerProfiles.currentApiConfigName
setSelectedConfigName(defaultConfigName)
// Apply the default config
const providerSettings = providerProfiles.apiConfigs[defaultConfigName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...globalSettings })
@ -169,6 +283,22 @@ export function NewRun() {
[clearErrors, setValue],
)
const onSelectConfig = useCallback(
(configName: string) => {
if (!importedSettings) {
return
}
setSelectedConfigName(configName)
setConfigPopoverOpen(false)
const providerSettings = importedSettings.apiConfigs[configName] ?? {}
setValue("model", getModelId(providerSettings) ?? "")
setValue("settings", { ...EVALS_SETTINGS, ...providerSettings, ...importedSettings.globalSettings })
},
[importedSettings, setValue],
)
return (
<>
<FormProvider {...form}>
@ -207,6 +337,63 @@ export function NewRun() {
className="hidden"
onChange={onImportSettings}
/>
{importedSettings && Object.keys(importedSettings.apiConfigs).length > 1 && (
<div className="space-y-1">
<Label>API Config</Label>
<Popover open={configPopoverOpen} onOpenChange={setConfigPopoverOpen}>
<PopoverTrigger asChild>
<Button
variant="input"
role="combobox"
aria-expanded={configPopoverOpen}
className="flex items-center justify-between w-full">
<div>{selectedConfigName || "Select config"}</div>
<ChevronsUpDown className="opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[var(--radix-popover-trigger-width)]">
<Command>
<CommandInput
placeholder="Search configs..."
className="h-9"
/>
<CommandList>
<CommandEmpty>No config found.</CommandEmpty>
<CommandGroup>
{Object.keys(importedSettings.apiConfigs).map(
(configName) => (
<CommandItem
key={configName}
value={configName}
onSelect={onSelectConfig}>
{configName}
{configName ===
importedSettings.currentApiConfigName && (
<span className="ml-2 text-xs text-muted-foreground">
(default)
</span>
)}
<Check
className={cn(
"ml-auto size-4",
configName ===
selectedConfigName
? "opacity-100"
: "opacity-0",
)}
/>
</CommandItem>
),
)}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
{settings && (
<SettingsDiff defaultSettings={EVALS_SETTINGS} customSettings={settings} />
)}
@ -306,18 +493,51 @@ export function NewRun() {
render={() => (
<FormItem>
<FormLabel>Exercises</FormLabel>
<Tabs
defaultValue="full"
onValueChange={(value) => setValue("suite", value as "full" | "partial")}>
<TabsList>
<TabsTrigger value="full">All</TabsTrigger>
<TabsTrigger value="partial">Some</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-2 flex-wrap">
<Tabs
defaultValue="full"
onValueChange={(value) => {
setValue("suite", value as "full" | "partial")
if (value === "full") {
setSelectedExercises([])
setValue("exercises", [])
}
}}>
<TabsList>
<TabsTrigger value="full">All</TabsTrigger>
<TabsTrigger value="partial">Some</TabsTrigger>
</TabsList>
</Tabs>
{suite === "partial" && languages.length > 0 && (
<div className="flex items-center gap-1 flex-wrap">
{languages.map((lang) => (
<Button
key={lang}
type="button"
variant={
isLanguageSelected(lang)
? "default"
: isLanguagePartiallySelected(lang)
? "secondary"
: "outline"
}
size="sm"
onClick={() => toggleLanguage(lang)}
className="text-xs capitalize">
{lang}
</Button>
))}
</div>
)}
</div>
{suite === "partial" && (
<MultiSelect
options={exercises.data?.map((path) => ({ value: path, label: path })) || []}
onValueChange={(value) => setValue("exercises", value)}
value={selectedExercises}
onValueChange={(value) => {
setSelectedExercises(value)
setValue("exercises", value)
}}
placeholder="Select"
variant="inverted"
maxCount={4}
@ -337,11 +557,14 @@ export function NewRun() {
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
defaultValue={[field.value]}
value={[field.value]}
min={CONCURRENCY_MIN}
max={CONCURRENCY_MAX}
step={1}
onValueChange={(value) => field.onChange(value[0])}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-concurrency", String(value[0]))
}}
/>
<div>{field.value}</div>
</div>
@ -360,11 +583,14 @@ export function NewRun() {
<FormControl>
<div className="flex flex-row items-center gap-2">
<Slider
defaultValue={[field.value]}
value={[field.value]}
min={TIMEOUT_MIN}
max={TIMEOUT_MAX}
step={1}
onValueChange={(value) => field.onChange(value[0])}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-timeout", String(value[0]))
}}
/>
<div>{field.value}</div>
</div>

View file

@ -1,11 +1,19 @@
import { useCallback, useState, useRef } from "react"
import Link from "next/link"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash } from "lucide-react"
import { useRouter } from "next/navigation"
import { Ellipsis, ClipboardList, Copy, Check, LoaderCircle, Trash, Settings } from "lucide-react"
import type { Run as EvalsRun, TaskMetrics as EvalsTaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { deleteRun } from "@/actions/runs"
import { formatCurrency, formatDuration, formatTokens, formatToolUsageSuccessRate } from "@/lib/formatters"
import {
formatCurrency,
formatDateTime,
formatDuration,
formatTokens,
formatToolUsageSuccessRate,
} from "@/lib/formatters"
import { useCopyRun } from "@/hooks/use-copy-run"
import {
Button,
@ -23,15 +31,23 @@ import {
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
ScrollArea,
} from "@/components/ui"
type RunProps = {
run: EvalsRun
taskMetrics: EvalsTaskMetrics | null
toolColumns: ToolName[]
}
export function Run({ run, taskMetrics }: RunProps) {
export function Run({ run, taskMetrics, toolColumns }: RunProps) {
const router = useRouter()
const [deleteRunId, setDeleteRunId] = useState<number>()
const [showSettings, setShowSettings] = useState(false)
const continueRef = useRef<HTMLButtonElement>(null)
const { isPending, copyRun, copied } = useCopyRun(run.id)
@ -48,10 +64,25 @@ export function Run({ run, taskMetrics }: RunProps) {
}
}, [deleteRunId])
const handleRowClick = useCallback(
(e: React.MouseEvent) => {
// Don't navigate if clicking on the dropdown menu
if ((e.target as HTMLElement).closest("[data-dropdown-trigger]")) {
return
}
router.push(`/runs/${run.id}`)
},
[router, run.id],
)
return (
<>
<TableRow>
<TableCell>{run.model}</TableCell>
<TableRow className="cursor-pointer hover:bg-muted/50" onClick={handleRowClick}>
<TableCell className="max-w-[200px] truncate">{run.model}</TableCell>
<TableCell>{run.settings?.apiProvider ?? "-"}</TableCell>
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
{formatDateTime(run.createdAt)}
</TableCell>
<TableCell>{run.passed}</TableCell>
<TableCell>{run.failed}</TableCell>
<TableCell>
@ -61,27 +92,33 @@ export function Run({ run, taskMetrics }: RunProps) {
</TableCell>
<TableCell>
{taskMetrics && (
<div className="flex items-center gap-1.5">
<div>{formatTokens(taskMetrics.tokensIn)}</div>/
<div>{formatTokens(taskMetrics.tokensOut)}</div>
</div>
)}
</TableCell>
<TableCell>
{taskMetrics?.toolUsage?.apply_diff && (
<div className="flex flex-row items-center gap-1.5">
<div>{taskMetrics.toolUsage.apply_diff.attempts}</div>
<div>/</div>
<div>{formatToolUsageSuccessRate(taskMetrics.toolUsage.apply_diff)}</div>
<div className="flex items-center gap-1">
<span>{formatTokens(taskMetrics.tokensIn)}</span>/
<span>{formatTokens(taskMetrics.tokensOut)}</span>
</div>
)}
</TableCell>
{toolColumns.map((toolName) => {
const usage = taskMetrics?.toolUsage?.[toolName]
return (
<TableCell key={toolName} className="text-xs text-center">
{usage ? (
<div className="flex flex-col items-center">
<span className="font-medium">{usage.attempts}</span>
<span className="text-muted-foreground">{formatToolUsageSuccessRate(usage)}</span>
</div>
) : (
<span className="text-muted-foreground">-</span>
)}
</TableCell>
)
})}
<TableCell>{taskMetrics && formatCurrency(taskMetrics.cost)}</TableCell>
<TableCell>{taskMetrics && formatDuration(taskMetrics.duration)}</TableCell>
<TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<DropdownMenu>
<Button variant="ghost" size="icon" asChild>
<DropdownMenuTrigger>
<DropdownMenuTrigger data-dropdown-trigger>
<Ellipsis />
</DropdownMenuTrigger>
</Button>
@ -94,6 +131,14 @@ export function Run({ run, taskMetrics }: RunProps) {
</div>
</Link>
</DropdownMenuItem>
{run.settings && (
<DropdownMenuItem onClick={() => setShowSettings(true)}>
<div className="flex items-center gap-1">
<Settings />
<div>View Settings</div>
</div>
</DropdownMenuItem>
)}
{run.taskMetricsId && (
<DropdownMenuItem onClick={() => copyRun()} disabled={isPending || copied}>
<div className="flex items-center gap-1">
@ -144,6 +189,18 @@ export function Run({ run, taskMetrics }: RunProps) {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={showSettings} onOpenChange={setShowSettings}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle>Run Settings</DialogTitle>
</DialogHeader>
<ScrollArea className="max-h-[60vh]">
<pre className="text-xs font-mono bg-muted p-4 rounded-md overflow-auto">
{JSON.stringify(run.settings, null, 2)}
</pre>
</ScrollArea>
</DialogContent>
</Dialog>
</>
)
}

View file

@ -1,40 +1,224 @@
"use client"
import { useMemo, useState } from "react"
import { useRouter } from "next/navigation"
import { Rocket } from "lucide-react"
import { ArrowDown, ArrowUp, ArrowUpDown, Rocket } from "lucide-react"
import type { Run, TaskMetrics } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
import { Button, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui"
import {
Button,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui"
import { Run as Row } from "@/components/home/run"
type RunWithTaskMetrics = Run & { taskMetrics: TaskMetrics | null }
type SortColumn = "model" | "provider" | "passed" | "failed" | "percent" | "cost" | "duration" | "createdAt"
type SortDirection = "asc" | "desc"
// Generate abbreviation from tool name (e.g., "read_file" -> "RF", "list_code_definition_names" -> "LCDN")
function getToolAbbreviation(toolName: string): string {
return toolName
.split("_")
.map((word) => word[0]?.toUpperCase() ?? "")
.join("")
}
function SortIcon({
column,
sortColumn,
sortDirection,
}: {
column: SortColumn
sortColumn: SortColumn | null
sortDirection: SortDirection
}) {
if (sortColumn !== column) {
return <ArrowUpDown className="ml-1 h-3 w-3 opacity-50" />
}
return sortDirection === "asc" ? <ArrowUp className="ml-1 h-3 w-3" /> : <ArrowDown className="ml-1 h-3 w-3" />
}
export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const router = useRouter()
const [sortColumn, setSortColumn] = useState<SortColumn | null>("createdAt")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
const handleSort = (column: SortColumn) => {
if (sortColumn === column) {
setSortDirection(sortDirection === "asc" ? "desc" : "asc")
} else {
setSortColumn(column)
setSortDirection("desc")
}
}
// Collect all unique tool names from all runs and sort by total attempts
const toolColumns = useMemo<ToolName[]>(() => {
const toolTotals = new Map<ToolName, number>()
for (const run of runs) {
if (run.taskMetrics?.toolUsage) {
for (const [toolName, usage] of Object.entries(run.taskMetrics.toolUsage)) {
const tool = toolName as ToolName
const current = toolTotals.get(tool) ?? 0
toolTotals.set(tool, current + usage.attempts)
}
}
}
// Sort by total attempts descending
return Array.from(toolTotals.entries())
.sort((a, b) => b[1] - a[1])
.map(([name]): ToolName => name)
}, [runs])
// Sort runs based on current sort column and direction
const sortedRuns = useMemo(() => {
if (!sortColumn) return runs
return [...runs].sort((a, b) => {
let aVal: string | number | Date | null = null
let bVal: string | number | Date | null = null
switch (sortColumn) {
case "model":
aVal = a.model
bVal = b.model
break
case "provider":
aVal = a.settings?.apiProvider ?? ""
bVal = b.settings?.apiProvider ?? ""
break
case "passed":
aVal = a.passed
bVal = b.passed
break
case "failed":
aVal = a.failed
bVal = b.failed
break
case "percent":
aVal = a.passed + a.failed > 0 ? a.passed / (a.passed + a.failed) : 0
bVal = b.passed + b.failed > 0 ? b.passed / (b.passed + b.failed) : 0
break
case "cost":
aVal = a.taskMetrics?.cost ?? 0
bVal = b.taskMetrics?.cost ?? 0
break
case "duration":
aVal = a.taskMetrics?.duration ?? 0
bVal = b.taskMetrics?.duration ?? 0
break
case "createdAt":
aVal = a.createdAt
bVal = b.createdAt
break
}
if (aVal === null || bVal === null) return 0
let comparison = 0
if (typeof aVal === "string" && typeof bVal === "string") {
comparison = aVal.localeCompare(bVal)
} else if (aVal instanceof Date && bVal instanceof Date) {
comparison = aVal.getTime() - bVal.getTime()
} else {
comparison = (aVal as number) - (bVal as number)
}
return sortDirection === "asc" ? comparison : -comparison
})
}, [runs, sortColumn, sortDirection])
// Calculate colSpan for empty state (7 base columns + dynamic tools + 3 end columns)
const totalColumns = 7 + toolColumns.length + 3
return (
<>
<Table className="border border-t-0">
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead>Passed</TableHead>
<TableHead>Failed</TableHead>
<TableHead>% Correct</TableHead>
<TableHead>Tokens In / Out</TableHead>
<TableHead>Diff Edits</TableHead>
<TableHead>Cost</TableHead>
<TableHead>Duration</TableHead>
<TableHead />
<TableHead
className="max-w-[200px] cursor-pointer select-none"
onClick={() => handleSort("model")}>
<div className="flex items-center">
Model
<SortIcon column="model" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("provider")}>
<div className="flex items-center">
Provider
<SortIcon column="provider" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("createdAt")}>
<div className="flex items-center">
Created
<SortIcon column="createdAt" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("passed")}>
<div className="flex items-center">
Passed
<SortIcon column="passed" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("failed")}>
<div className="flex items-center">
Failed
<SortIcon column="failed" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("percent")}>
<div className="flex items-center">
%
<SortIcon column="percent" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead>Tokens</TableHead>
{toolColumns.map((toolName) => (
<TableHead key={toolName} className="text-xs text-center">
<Tooltip>
<TooltipTrigger>{getToolAbbreviation(toolName)}</TooltipTrigger>
<TooltipContent>{toolName}</TooltipContent>
</Tooltip>
</TableHead>
))}
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("cost")}>
<div className="flex items-center">
Cost
<SortIcon column="cost" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead className="cursor-pointer select-none" onClick={() => handleSort("duration")}>
<div className="flex items-center">
Duration
<SortIcon column="duration" sortColumn={sortColumn} sortDirection={sortDirection} />
</div>
</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{runs.length ? (
runs.map(({ taskMetrics, ...run }) => <Row key={run.id} run={run} taskMetrics={taskMetrics} />)
{sortedRuns.length ? (
sortedRuns.map(({ taskMetrics, ...run }) => (
<Row key={run.id} run={run} taskMetrics={taskMetrics} toolColumns={toolColumns} />
))
) : (
<TableRow>
<TableCell colSpan={9} className="text-center">
<TableCell colSpan={totalColumns} className="text-center">
No eval runs yet.
<Button variant="link" onClick={() => router.push("/runs/new")}>
Launch

View file

@ -48,7 +48,10 @@ interface MultiSelectProps extends React.HTMLAttributes<HTMLDivElement>, Variant
*/
onValueChange: (value: string[]) => void
/** The default selected values when the component mounts. */
/** The controlled selected values. When provided, the component becomes controlled. */
value?: string[]
/** The default selected values when the component mounts (uncontrolled mode). */
defaultValue?: string[]
/**
@ -89,6 +92,7 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
options,
onValueChange,
variant,
value,
defaultValue = [],
placeholder = "Select options",
maxCount = 3,
@ -98,17 +102,30 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
},
ref,
) => {
const [selectedValues, setSelectedValues] = React.useState<string[]>(defaultValue)
const [internalSelectedValues, setInternalSelectedValues] = React.useState<string[]>(defaultValue)
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false)
// Use controlled value if provided, otherwise use internal state
const isControlled = value !== undefined
const selectedValues = isControlled ? value : internalSelectedValues
const setSelectedValues = React.useCallback(
(newValues: string[]) => {
if (!isControlled) {
setInternalSelectedValues(newValues)
}
onValueChange(newValues)
},
[isControlled, onValueChange],
)
const handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === "Enter") {
setIsPopoverOpen(true)
} else if (event.key === "Backspace" && !event.currentTarget.value) {
const newSelectedValues = [...selectedValues]
newSelectedValues.pop()
if (!selectedValues.length) return
const newSelectedValues = selectedValues.slice(0, -1)
setSelectedValues(newSelectedValues)
onValueChange(newSelectedValues)
}
}
@ -117,7 +134,6 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
? selectedValues.filter((value) => value !== option)
: [...selectedValues, option]
setSelectedValues(newSelectedValues)
onValueChange(newSelectedValues)
}
const handleTogglePopover = () => {
@ -127,7 +143,6 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
const clearExtraOptions = () => {
const newSelectedValues = selectedValues.slice(0, maxCount)
setSelectedValues(newSelectedValues)
onValueChange(newSelectedValues)
}
const searchResultsRef = React.useRef<Map<string, number>>(new Map())
@ -141,12 +156,10 @@ export const MultiSelect = React.forwardRef<HTMLDivElement, MultiSelectProps>(
selectedValues.sort().join(",") === values.sort().join(",")
) {
setSelectedValues([])
onValueChange([])
return
}
setSelectedValues(values)
onValueChange(values)
}
const onFilter = React.useCallback(

View file

@ -51,7 +51,7 @@ export const getRooCodeCloudModels = async (): Promise<RooCodeCloudModel[]> => {
return []
}
return result.data.data.sort((a, b) => a.name.localeCompare(b.name))
return result.data.data.filter((model) => !model.deprecated).sort((a, b) => a.name.localeCompare(b.name))
}
export const useRooCodeCloudModels = () => {

View file

@ -46,3 +46,13 @@ export const formatTokens = (tokens: number) => {
export const formatToolUsageSuccessRate = (usage: { attempts: number; failures: number }) =>
usage.attempts === 0 ? "0%" : `${(((usage.attempts - usage.failures) / usage.attempts) * 100).toFixed(1)}%`
export const formatDateTime = (date: Date) => {
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(date)
}