mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-14 23:21:19 +00:00
web-evals: usehooks-ts useLocalStorage for persisted UI state
This commit is contained in:
parent
41c5ff6076
commit
de4256d1d4
10 changed files with 266 additions and 145 deletions
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
32
apps/web-evals/src/lib/__tests__/storage.spec.ts
Normal file
32
apps/web-evals/src/lib/__tests__/storage.spec.ts
Normal 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")
|
||||
})
|
||||
})
|
||||
43
apps/web-evals/src/lib/storage.ts
Normal file
43
apps/web-evals/src/lib/storage.ts
Normal 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
|
||||
}
|
||||
37
apps/web-evals/src/lib/tool-groups.ts
Normal file
37
apps/web-evals/src/lib/tool-groups.ts
Normal 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)
|
||||
}
|
||||
15
apps/web-evals/src/types/usehooks-ts.d.ts
vendored
Normal file
15
apps/web-evals/src/types/usehooks-ts.d.ts
vendored
Normal 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
14
pnpm-lock.yaml
generated
|
|
@ -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: {}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue