web-evals: usehooks-ts useLocalStorage for persisted UI state

This commit is contained in:
Hannes Rudolph 2026-01-07 08:05:05 -07:00
parent 41c5ff6076
commit de4256d1d4
10 changed files with 266 additions and 145 deletions

View file

@ -47,6 +47,7 @@
"tailwind-merge": "^3.3.0",
"tailwindcss-animate": "^1.0.7",
"vaul": "^1.1.2",
"usehooks-ts": "^3.1.0",
"zod": "^3.25.61"
},
"devDependencies": {

View file

@ -3,6 +3,7 @@
import { useMemo, useState, useCallback, useEffect, Fragment } from "react"
import { toast } from "sonner"
import { LoaderCircle, FileText, Copy, Check, StopCircle, List, Layers } from "lucide-react"
import { useLocalStorage } from "usehooks-ts"
import type { Run, TaskMetrics as _TaskMetrics, Task } from "@roo-code/evals"
import type { ToolName } from "@roo-code/types"
@ -253,19 +254,24 @@ export function Run({ run }: { run: Run }) {
const [copied, setCopied] = useState(false)
const [showKillDialog, setShowKillDialog] = useState(false)
const [isKilling, setIsKilling] = useState(false)
const [groupByStatus, setGroupByStatus] = useState(() => {
// Initialize from localStorage if available (client-side only)
if (typeof window !== "undefined") {
const stored = localStorage.getItem("evals-group-by-status")
return stored === "true"
}
return false
})
// Persist groupByStatus to localStorage
useEffect(() => {
localStorage.setItem("evals-group-by-status", String(groupByStatus))
}, [groupByStatus])
function deserializeBoolean(value: string): boolean {
// Support both raw-string storage and default `useLocalStorage` JSON serialization.
if (value === "true") return true
if (value === "false") return false
try {
const parsed: unknown = JSON.parse(value)
return typeof parsed === "boolean" ? parsed : false
} catch {
return false
}
}
const [groupByStatus, setGroupByStatus] = useLocalStorage<boolean>("evals-group-by-status", false, {
serializer: (value: boolean) => String(value),
deserializer: deserializeBoolean,
initializeWithValue: false,
})
// Determine if run is still active (has heartbeat or runners)
const isRunActive = !run.taskMetricsId && (!!heartbeat || (runners && runners.length > 0))

View file

@ -7,6 +7,7 @@ import { useQuery } from "@tanstack/react-query"
import { useForm, FormProvider } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import { toast } from "sonner"
import { useLocalStorage } from "usehooks-ts"
import {
X,
Rocket,
@ -47,6 +48,7 @@ import {
ITERATIONS_DEFAULT,
} from "@/lib/schemas"
import { cn } from "@/lib/utils"
import { deserializeNumber, deserializeString, deserializeStringArray } from "@/lib/storage"
import { loadRooLastModelSelection, saveRooLastModelSelection } from "@/lib/roo-last-model-selection"
import { normalizeCreateRunForSubmit } from "@/lib/normalize-create-run"
@ -115,6 +117,40 @@ export function NewRun() {
const [commandExecutionTimeout, setCommandExecutionTimeout] = useState(20)
const [terminalShellIntegrationTimeout, setTerminalShellIntegrationTimeout] = useState(30) // seconds
const [savedConcurrency, setSavedConcurrency] = useLocalStorage<number>("evals-concurrency", CONCURRENCY_DEFAULT, {
serializer: (value: number) => String(value),
deserializer: (raw: string) => deserializeNumber(raw) ?? CONCURRENCY_DEFAULT,
initializeWithValue: false,
})
const [savedTimeout, setSavedTimeout] = useLocalStorage<number>("evals-timeout", TIMEOUT_DEFAULT, {
serializer: (value: number) => String(value),
deserializer: (raw: string) => deserializeNumber(raw) ?? TIMEOUT_DEFAULT,
initializeWithValue: false,
})
const [savedCommandTimeout, setSavedCommandTimeout] = useLocalStorage<number>(
"evals-command-execution-timeout",
20,
{
serializer: (value: number) => String(value),
deserializer: (raw: string) => deserializeNumber(raw) ?? 20,
initializeWithValue: false,
},
)
const [savedShellTimeout, setSavedShellTimeout] = useLocalStorage<number>("evals-shell-integration-timeout", 30, {
serializer: (value: number) => String(value),
deserializer: (raw: string) => deserializeNumber(raw) ?? 30,
initializeWithValue: false,
})
const [savedSuite, setSavedSuite] = useLocalStorage<"full" | "partial">("evals-suite", "full", {
serializer: (value: "full" | "partial") => value,
deserializer: (raw: string) => (deserializeString(raw) === "partial" ? "partial" : "full"),
initializeWithValue: false,
})
const [savedExercises, setSavedExercises] = useLocalStorage<string[]>("evals-exercises", [], {
deserializer: deserializeStringArray,
initializeWithValue: false,
})
const [modelSelections, setModelSelections] = useState<ModelSelection[]>([
{ id: crypto.randomUUID(), model: "", popoverOpen: false },
])
@ -188,66 +224,30 @@ export function NewRun() {
register("exercises")
}, [register])
// Load settings from localStorage on mount
// Sync persisted settings into the form/state (SSR-safe)
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)
}
if (savedConcurrency >= CONCURRENCY_MIN && savedConcurrency <= CONCURRENCY_MAX) {
setValue("concurrency", savedConcurrency)
}
if (savedTimeout >= TIMEOUT_MIN && savedTimeout <= TIMEOUT_MAX) {
setValue("timeout", savedTimeout)
}
if (savedCommandTimeout >= 20 && savedCommandTimeout <= 60) {
setCommandExecutionTimeout(savedCommandTimeout)
}
if (savedShellTimeout >= 30 && savedShellTimeout <= 60) {
setTerminalShellIntegrationTimeout(savedShellTimeout)
}
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)
}
}
const savedCommandTimeout = localStorage.getItem("evals-command-execution-timeout")
if (savedCommandTimeout) {
const parsed = parseInt(savedCommandTimeout, 10)
if (!isNaN(parsed) && parsed >= 20 && parsed <= 60) {
setCommandExecutionTimeout(parsed)
}
}
const savedShellTimeout = localStorage.getItem("evals-shell-integration-timeout")
if (savedShellTimeout) {
const parsed = parseInt(savedShellTimeout, 10)
if (!isNaN(parsed) && parsed >= 30 && parsed <= 60) {
setTerminalShellIntegrationTimeout(parsed)
}
}
const savedSuite = localStorage.getItem("evals-suite")
setValue("suite", savedSuite)
if (savedSuite === "partial") {
setValue("suite", "partial")
const savedExercises = localStorage.getItem("evals-exercises")
if (savedExercises) {
try {
const parsed = JSON.parse(savedExercises) as string[]
if (Array.isArray(parsed)) {
setSelectedExercises(parsed)
setValue("exercises", parsed)
}
} catch {
// Invalid JSON, ignore.
}
}
setSelectedExercises(savedExercises)
setValue("exercises", savedExercises)
} else {
setSelectedExercises([])
setValue("exercises", [])
}
}, [setValue])
}, [savedConcurrency, savedTimeout, savedCommandTimeout, savedShellTimeout, savedSuite, savedExercises, setValue])
// Track previous provider to detect switches
const [prevProvider, setPrevProvider] = useState(provider)
@ -344,9 +344,9 @@ export function NewRun() {
setSelectedExercises(newSelected)
setValue("exercises", newSelected)
localStorage.setItem("evals-exercises", JSON.stringify(newSelected))
setSavedExercises(newSelected)
},
[getExercisesForLanguage, selectedExercises, setValue],
[getExercisesForLanguage, selectedExercises, setSavedExercises, setValue],
)
const isLanguageSelected = useCallback(
@ -863,12 +863,13 @@ export function NewRun() {
<Tabs
value={suite}
onValueChange={(value) => {
setValue("suite", value as "full" | "partial")
localStorage.setItem("evals-suite", value)
if (value === "full") {
const next = value === "partial" ? "partial" : "full"
setValue("suite", next)
setSavedSuite(next)
if (next === "full") {
setSelectedExercises([])
setValue("exercises", [])
localStorage.removeItem("evals-exercises")
setSavedExercises([])
}
}}>
<TabsList>
@ -905,7 +906,7 @@ export function NewRun() {
onValueChange={(value) => {
setSelectedExercises(value)
setValue("exercises", value)
localStorage.setItem("evals-exercises", JSON.stringify(value))
setSavedExercises(value)
}}
placeholder="Select"
variant="inverted"
@ -934,7 +935,7 @@ export function NewRun() {
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-concurrency", String(value[0]))
setSavedConcurrency(value[0] ?? CONCURRENCY_DEFAULT)
}}
/>
<div className="w-6 text-right">{field.value}</div>
@ -960,7 +961,7 @@ export function NewRun() {
step={1}
onValueChange={(value) => {
field.onChange(value[0])
localStorage.setItem("evals-timeout", String(value[0]))
setSavedTimeout(value[0] ?? TIMEOUT_DEFAULT)
}}
/>
<div className="w-6 text-right">{field.value}</div>
@ -1024,7 +1025,7 @@ export function NewRun() {
onValueChange={([value]) => {
if (value !== undefined) {
setCommandExecutionTimeout(value)
localStorage.setItem("evals-command-execution-timeout", String(value))
setSavedCommandTimeout(value)
}
}}
/>
@ -1056,7 +1057,7 @@ export function NewRun() {
onValueChange={([value]) => {
if (value !== undefined) {
setTerminalShellIntegrationTimeout(value)
localStorage.setItem("evals-shell-integration-timeout", String(value))
setSavedShellTimeout(value)
}
}}
/>

View file

@ -44,13 +44,7 @@ import {
ScrollArea,
} from "@/components/ui"
// Tool group type (same as in runs.tsx)
type ToolGroup = {
id: string
name: string
icon: string
tools: string[]
}
import type { ToolGroup } from "@/lib/tool-groups"
type RunProps = {
run: EvalsRun

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useMemo, useState, memo } from "react"
import { useRouter } from "next/navigation"
import { useLocalStorage } from "usehooks-ts"
import {
ArrowDown,
ArrowUp,
@ -88,6 +89,8 @@ import {
TooltipTrigger,
} from "@/components/ui"
import { Run as Row } from "@/components/home/run"
import { deserializeEnum, deserializeStringArray } from "@/lib/storage"
import { type ToolGroup, deserializeToolGroups, serializeToolGroups } from "@/lib/tool-groups"
// Available icons for tool groups
const TOOL_GROUP_ICONS: { name: string; icon: LucideIcon }[] = [
@ -124,14 +127,6 @@ const TOOL_GROUP_ICONS: { name: string; icon: LucideIcon }[] = [
{ name: "tag", icon: Tag },
]
// Tool group type
export type ToolGroup = {
id: string
name: string
icon: string
tools: string[]
}
// Helper to get icon component by name
function getIconByName(name: string): LucideIcon {
return TOOL_GROUP_ICONS.find((i) => i.name === name)?.icon ?? Combine
@ -256,6 +251,8 @@ type SortDirection = "asc" | "desc"
type TimeframeOption = "all" | "24h" | "7d" | "30d" | "90d"
const TIMEFRAME_OPTION_VALUES: ReadonlySet<TimeframeOption> = new Set(["all", "24h", "7d", "30d", "90d"])
const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [
{ value: "all", label: "All time" },
{ value: "24h", label: "Last 24 hours" },
@ -264,6 +261,10 @@ const TIMEFRAME_OPTIONS: { value: TimeframeOption; label: string }[] = [
{ value: "90d", label: "Last 90 days" },
]
function deserializeTimeframe(value: string): TimeframeOption {
return deserializeEnum(value, TIMEFRAME_OPTION_VALUES, "all")
}
// LocalStorage keys
const STORAGE_KEYS = {
TIMEFRAME: "evals-runs-timeframe",
@ -317,35 +318,26 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const [sortColumn, setSortColumn] = useState<SortColumn | null>("createdAt")
const [sortDirection, setSortDirection] = useState<SortDirection>("desc")
// Filter state - initialize from localStorage
const [timeframeFilter, setTimeframeFilter] = useState<TimeframeOption>(() => {
if (typeof window === "undefined") return "all"
const stored = localStorage.getItem(STORAGE_KEYS.TIMEFRAME)
return (stored as TimeframeOption) || "all"
// Filter state (persistent)
const [timeframeFilter, setTimeframeFilter] = useLocalStorage<TimeframeOption>(STORAGE_KEYS.TIMEFRAME, "all", {
serializer: (value: TimeframeOption) => value, // keep legacy raw-string storage
deserializer: deserializeTimeframe,
initializeWithValue: false,
})
const [modelFilter, setModelFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.MODEL_FILTER)
return stored ? JSON.parse(stored) : []
const [modelFilter, setModelFilter] = useLocalStorage<string[]>(STORAGE_KEYS.MODEL_FILTER, [], {
deserializer: deserializeStringArray,
initializeWithValue: false,
})
const [providerFilter, setProviderFilter] = useState<string[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.PROVIDER_FILTER)
return stored ? JSON.parse(stored) : []
const [providerFilter, setProviderFilter] = useLocalStorage<string[]>(STORAGE_KEYS.PROVIDER_FILTER, [], {
deserializer: deserializeStringArray,
initializeWithValue: false,
})
// Tool groups state - initialize from localStorage
const [toolGroups, setToolGroups] = useState<ToolGroup[]>(() => {
if (typeof window === "undefined") return []
const stored = localStorage.getItem(STORAGE_KEYS.TOOL_GROUPS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
// Tool groups state (persistent)
const [toolGroups, setToolGroups] = useLocalStorage<ToolGroup[]>(STORAGE_KEYS.TOOL_GROUPS, [], {
serializer: serializeToolGroups,
deserializer: deserializeToolGroups,
initializeWithValue: false,
})
// Tool group editor dialog state
@ -357,23 +349,6 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const [showDeleteOldConfirm, setShowDeleteOldConfirm] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
// Persist filters to localStorage
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.TIMEFRAME, timeframeFilter)
}, [timeframeFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.MODEL_FILTER, JSON.stringify(modelFilter))
}, [modelFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.PROVIDER_FILTER, JSON.stringify(providerFilter))
}, [providerFilter])
useEffect(() => {
localStorage.setItem(STORAGE_KEYS.TOOL_GROUPS, JSON.stringify(toolGroups))
}, [toolGroups])
// Count incomplete runs (runs without taskMetricsId)
const incompleteRunsCount = useMemo(() => {
return runs.filter((run) => run.taskMetrics === null).length
@ -618,8 +593,8 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
const handleSaveGroup = useCallback(
(group: ToolGroup) => {
setToolGroups((prev) => {
const existingIndex = prev.findIndex((g) => g.id === group.id)
setToolGroups((prev: ToolGroup[]) => {
const existingIndex = prev.findIndex((g: ToolGroup) => g.id === group.id)
if (existingIndex >= 0) {
// Update existing group
const newGroups = [...prev]
@ -632,13 +607,16 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
})
toast.success(editingGroup ? "Group updated" : "Group created")
},
[editingGroup],
[editingGroup, setToolGroups],
)
const handleDeleteGroup = useCallback((groupId: string) => {
setToolGroups((prev) => prev.filter((g) => g.id !== groupId))
toast.success("Group deleted")
}, [])
const handleDeleteGroup = useCallback(
(groupId: string) => {
setToolGroups((prev: ToolGroup[]) => prev.filter((g: ToolGroup) => g.id !== groupId))
toast.success("Group deleted")
},
[setToolGroups],
)
// Get available tools for group editor (tools not in other groups)
const availableToolsForEditor = useMemo(() => {
@ -715,7 +693,7 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
<DropdownMenuContent align="start" className="w-64">
{toolGroups.length > 0 ? (
<>
{toolGroups.map((group) => {
{toolGroups.map((group: ToolGroup) => {
const IconComponent = getIconByName(group.icon)
return (
<DropdownMenuItem
@ -858,7 +836,7 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
</TableHead>
<TableHead>Tokens</TableHead>
{/* Tool Group Columns */}
{toolGroups.map((group) => {
{toolGroups.map((group: ToolGroup) => {
const IconComponent = getIconByName(group.icon)
return (
<TableHead key={group.id} className="text-center">
@ -870,7 +848,7 @@ export function Runs({ runs }: { runs: RunWithTaskMetrics[] }) {
<TooltipContent>
<div className="text-xs">
<div className="font-semibold mb-1">{group.name}</div>
{group.tools.map((tool) => (
{group.tools.map((tool: string) => (
<div key={tool}>{tool}</div>
))}
</div>

View file

@ -0,0 +1,32 @@
import { deserializeBoolean, deserializeEnum, deserializeNumber, deserializeStringArray } from "../storage"
describe("storage deserializers", () => {
it("deserializeNumber supports raw numbers and JSON numbers", () => {
expect(deserializeNumber("42")).toBe(42)
expect(deserializeNumber("3.14")).toBe(3.14)
expect(deserializeNumber('"nope"')).toBeUndefined()
expect(deserializeNumber("{bad json")).toBeUndefined()
})
it("deserializeStringArray returns [] on invalid input", () => {
expect(deserializeStringArray("[]")).toEqual([])
expect(deserializeStringArray('["a","b"]')).toEqual(["a", "b"])
expect(deserializeStringArray("[1,2]")).toEqual([])
expect(deserializeStringArray("{bad json")).toEqual([])
})
it("deserializeBoolean supports legacy raw strings and JSON booleans", () => {
expect(deserializeBoolean("true")).toBe(true)
expect(deserializeBoolean("false")).toBe(false)
expect(deserializeBoolean('"true"')).toBe(false)
expect(deserializeBoolean("{bad json")).toBe(false)
})
it("deserializeEnum supports legacy raw strings and JSON strings", () => {
const allowed = new Set(["full", "partial"] as const)
expect(deserializeEnum("full", allowed, "full")).toBe("full")
expect(deserializeEnum('"partial"', allowed, "full")).toBe("partial")
expect(deserializeEnum("{bad json", allowed, "full")).toBe("full")
expect(deserializeEnum("other", allowed, "full")).toBe("full")
})
})

View file

@ -0,0 +1,43 @@
export function tryParseJson(raw: string): unknown {
try {
return JSON.parse(raw)
} catch {
return undefined
}
}
export function deserializeNumber(raw: string): number | undefined {
const parsed = tryParseJson(raw)
if (typeof parsed === "number" && Number.isFinite(parsed)) return parsed
const asNumber = Number(raw)
return Number.isFinite(asNumber) ? asNumber : undefined
}
export function deserializeString(raw: string): string {
const parsed = tryParseJson(raw)
return typeof parsed === "string" ? parsed : raw
}
export function deserializeStringArray(raw: string): string[] {
const parsed = tryParseJson(raw)
return Array.isArray(parsed) && parsed.every((v) => typeof v === "string") ? parsed : []
}
export function deserializeBoolean(raw: string): boolean {
// Support legacy raw-string storage and default JSON serialization.
if (raw === "true") return true
if (raw === "false") return false
const parsed = tryParseJson(raw)
return typeof parsed === "boolean" ? parsed : false
}
export function deserializeEnum<T extends string>(raw: string, allowed: ReadonlySet<T>, fallback: T): T {
if (allowed.has(raw as T)) return raw as T
const parsed = tryParseJson(raw)
if (typeof parsed === "string" && allowed.has(parsed as T)) return parsed as T
return fallback
}

View file

@ -0,0 +1,37 @@
import { deserializeStringArray, tryParseJson } from "@/lib/storage"
export type ToolGroup = {
id: string
name: string
icon: string
tools: string[]
}
function isToolGroup(value: unknown): value is ToolGroup {
if (!value || typeof value !== "object") return false
const v = value as Record<string, unknown>
return (
typeof v.id === "string" &&
typeof v.name === "string" &&
typeof v.icon === "string" &&
Array.isArray(v.tools) &&
v.tools.every((t) => typeof t === "string")
)
}
export function deserializeToolGroups(raw: string): ToolGroup[] {
// Be tolerant: default `useLocalStorage` JSON serialization will be valid JSON.
// If consumers previously wrote non-JSON, treat as empty.
const parsed = tryParseJson(raw)
if (!Array.isArray(parsed)) return []
return parsed.filter(isToolGroup)
}
export function serializeToolGroups(groups: ToolGroup[]): string {
return JSON.stringify(groups)
}
export function deserializeToolGroupTools(raw: string): string[] {
// Useful if we ever need to read just tool arrays.
return deserializeStringArray(raw)
}

View file

@ -0,0 +1,15 @@
declare module "usehooks-ts" {
import type { Dispatch, SetStateAction } from "react"
export type UseLocalStorageOptions<T> = {
serializer?: (value: T) => string
deserializer?: (value: string) => T
initializeWithValue?: boolean
}
export function useLocalStorage<T>(
key: string,
initialValue: T | (() => T),
options?: UseLocalStorageOptions<T>,
): [T, Dispatch<SetStateAction<T>>, () => void]
}

14
pnpm-lock.yaml generated
View file

@ -264,6 +264,9 @@ importers:
tailwindcss-animate:
specifier: ^1.0.7
version: 1.0.7(tailwindcss@4.1.6)
usehooks-ts:
specifier: ^3.1.0
version: 3.1.1(react@18.3.1)
vaul:
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
@ -9990,6 +9993,12 @@ packages:
peerDependencies:
react: '>=16.8'
usehooks-ts@3.1.1:
resolution: {integrity: sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==}
engines: {node: '>=16.15.0'}
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc
utf8-byte-length@1.0.5:
resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==}
@ -20723,6 +20732,11 @@ snapshots:
howler: 2.2.4
react: 18.3.1
usehooks-ts@3.1.1(react@18.3.1):
dependencies:
lodash.debounce: 4.0.8
react: 18.3.1
utf8-byte-length@1.0.5: {}
util-deprecate@1.0.2: {}