Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .add/tasks/evals-console.d/runs/1.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
type: Run
runtime: process
task: /tasks/evals-console.md
computation: "bash tmp/evals_gate.sh /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml"
receipt:
kind: test-ids
ids: 13/13 reported
exit: 0
freshness: mtime
at: 2026-08-14
stdout: 'merge_junit: wrote 13 testcases -> /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml'
note: ''
passed:
- tests.evals_admin.test_evals_admin_console::test_admin_evals_authoring_session_writes
- tests.evals_admin.test_evals_admin_console::test_admin_evals_has_no_launch_and_no_raw_key
- tests.evals_admin.test_evals_admin_console::test_admin_evals_session_scoped_reads
- tests.evals_admin.test_evals_admin_console::test_admin_evals_verdict_matches_v1_reuse
generated: { by: process:run, at: 2026-08-14 }
---
29 changes: 29 additions & 0 deletions .add/tasks/evals-console.d/runs/2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
type: Run
runtime: process
task: /tasks/evals-console.md
computation: "bash tmp/evals_gate.sh /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml"
receipt:
kind: test-ids
ids: 13/13 reported
exit: 0
freshness: mtime
at: 2026-08-14
stdout: 'merge_junit: wrote 13 testcases -> /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml'
note: ''
passed:
- a11y.spec.ts::test_evals_routes_axe_clean
- tests.evals_admin.test_evals_admin_console::test_admin_evals_authoring_session_writes
- tests.evals_admin.test_evals_admin_console::test_admin_evals_has_no_launch_and_no_raw_key
- tests.evals_admin.test_evals_admin_console::test_admin_evals_session_scoped_reads
- tests.evals_admin.test_evals_admin_console::test_admin_evals_verdict_matches_v1_reuse
- tests/evals-console.test.tsx::test_case_diff_shows_expected_actual_reason
- tests/evals-console.test.tsx::test_empty_sets_shows_create_affordance
- tests/evals-console.test.tsx::test_error_state_names_subsystem_not_user_data
- tests/evals-console.test.tsx::test_no_baseline_run_renders_explicit_state
- tests/evals-console.test.tsx::test_pending_run_is_not_a_verdict
- tests/evals-console.test.tsx::test_run_page_leads_with_verdict_banner
- tests/evals-console.test.tsx::test_run_rows_keyboard_navigable
- tests/evals-console.test.tsx::test_section_fail_closed_for_loading_identity
generated: { by: process:run, at: 2026-08-14 }
---
29 changes: 29 additions & 0 deletions .add/tasks/evals-console.d/runs/3.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
type: Run
runtime: process
task: /tasks/evals-console.md
computation: "bash tmp/evals_gate.sh /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml"
receipt:
kind: test-ids
ids: 13/13 reported
exit: 0
freshness: mtime
at: 2026-08-14
stdout: 'merge_junit: wrote 13 testcases -> /Users/tindang/workspaces/tind-repo/ai-proxy/tmp/junit_merged.xml'
note: ''
passed:
- a11y.spec.ts::test_evals_routes_axe_clean
- tests.evals_admin.test_evals_admin_console::test_admin_evals_has_no_launch_and_no_raw_key
- tests.evals_admin.test_evals_admin_console::test_admin_evals_session_pins_baseline
- tests.evals_admin.test_evals_admin_console::test_admin_evals_session_scoped_reads
- tests.evals_admin.test_evals_admin_console::test_admin_evals_verdict_matches_v1_reuse
- tests/evals-console.test.tsx::test_case_diff_shows_expected_actual_reason
- tests/evals-console.test.tsx::test_empty_sets_points_to_api
- tests/evals-console.test.tsx::test_error_state_names_subsystem_not_user_data
- tests/evals-console.test.tsx::test_no_baseline_run_renders_explicit_state
- tests/evals-console.test.tsx::test_pending_run_is_not_a_verdict
- tests/evals-console.test.tsx::test_run_page_leads_with_verdict_banner
- tests/evals-console.test.tsx::test_run_rows_keyboard_navigable
- tests/evals-console.test.tsx::test_section_fail_closed_for_loading_identity
generated: { by: process:run, at: 2026-08-14 }
---
95 changes: 78 additions & 17 deletions .add/tasks/evals-console.md

Large diffs are not rendered by default.

19 changes: 19 additions & 0 deletions apps/dashboard/app/(app)/app/evals/[setId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/**
* /app/evals/[setId] — one eval set's cases/runs/baseline. Follows the same Next.js
* 15+ Promise-based `params` convention as app/(app)/app/invoices/[invoiceId]/page.tsx
* — a thin Server Component wrapper; all data-fetching, states, and RBAC surfacing
* live in SetDetailPage itself.
*/

import { SetDetailPage } from "@/components/evals/SetDetailPage";

export const metadata = { title: "Hydroa" };

interface PageProps {
params: Promise<{ setId: string }>;
}

export default async function EvalSetDetailRoute({ params }: PageProps) {
const { setId } = await params;
return <SetDetailPage setId={setId} />;
}
18 changes: 18 additions & 0 deletions apps/dashboard/app/(app)/app/evals/[setId]/runs/[runId]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* /app/evals/[setId]/runs/[runId] — a run's verdict, leading with <VerdictBanner>
* before the per-case diff drill-down (the signature element of this console). Same
* thin Server Component wrapper convention as the sibling evals routes.
*/

import { RunVerdictPage } from "@/components/evals/RunVerdictPage";

export const metadata = { title: "Hydroa" };

interface PageProps {
params: Promise<{ setId: string; runId: string }>;
}

export default async function EvalRunVerdictRoute({ params }: PageProps) {
const { setId, runId } = await params;
return <RunVerdictPage setId={setId} runId={runId} />;
}
7 changes: 7 additions & 0 deletions apps/dashboard/app/(app)/app/evals/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { EvalsListPage } from "@/components/evals/EvalsListPage";

export const metadata = { title: "Hydroa" };

export default function EvalsRoute() {
return <EvalsListPage />;
}
27 changes: 27 additions & 0 deletions apps/dashboard/components/evals/CaseDiffList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* CaseDiffList — evals-console TASK.md §3 CONTRACT M4. Renders one CaseDiffRow per
* enriched case result. Eval lists are NOT cursor-paginated (design_requirements) — the
* full array from GET /admin/evals/runs/{run_id}/cases renders, no pagination stack.
*/

import { Empty } from "@/components/ui";
import { CaseDiffRow } from "./CaseDiffRow";
import type { EvalCaseResult } from "./types";

export interface CaseDiffListProps {
results: EvalCaseResult[];
}

export function CaseDiffList({ results }: CaseDiffListProps) {
if (results.length === 0) {
return <Empty title="No case results yet" description="This run has not produced any case results." />;
}

return (
<ul data-testid="case-diff-list" aria-label="Case results" className="flex flex-col gap-3">
{results.map((result) => (
<CaseDiffRow key={result.eval_case_id} result={result} />
))}
</ul>
);
}
84 changes: 84 additions & 0 deletions apps/dashboard/components/evals/CaseDiffRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* CaseDiffRow — evals-console TASK.md §3 CONTRACT M4, the SIGNATURE element of this
* whole console: an expected-vs-actual DIFF per case, never a bare colored dot.
*
* GET /admin/evals/runs/{run_id}/cases is the ENRICHED, frozen contract: a "completed"
* row can still have FAILED its assertion, so it carries `passed` — the AUTHORITATIVE
* per-case verdict from the same deterministic scorer the run verdict counts with. This
* component renders that bool directly and NEVER re-derives pass/fail from the payload:
* a client-side re-implementation would fork scoring and, for a `contains` assertion,
* disagree with the banner (expected "echo" vs actual "echo:one" is a PASS the scorer
* sees but string equality would miss). A "refused"/"errored"/"pending" row carries NO
* response_text and NO `passed` — this component renders status + reason for those and
* NEVER fabricates an "actual" value.
*/

import { EvalStatusBadge } from "./EvalStatusBadge";
import { formatExpected } from "./format";
import type { EvalCaseResult } from "./types";

export type CaseOutcome = "pass" | "fail" | "refused" | "errored" | "pending";

/**
* Map a case row to its DISPLAY outcome. "refused"/"errored"/"pending" pass straight
* through (no verdict to show); a "completed" row shows the backend's authoritative
* `passed` bool — pass/fail is never recomputed client-side. A completed row missing
* `passed` (should not happen on the frozen contract) degrades defensively to "fail".
*/
export function deriveCaseOutcome(result: EvalCaseResult): CaseOutcome {
if (result.status === "refused") return "refused";
if (result.status === "errored") return "errored";
if (result.status === "pending") return "pending";
return result.passed === true ? "pass" : "fail";
}

export interface CaseDiffRowProps {
result: EvalCaseResult;
}

export function CaseDiffRow({ result }: CaseDiffRowProps) {
const outcome = deriveCaseOutcome(result);
const expectedDisplay = formatExpected(result.assertion);
const isCompleted = result.status === "completed";

return (
<li
data-testid={`case-diff-${result.eval_case_id}`}
className="flex flex-col gap-3 rounded-lg border border-border bg-card p-4"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-xs text-muted-foreground">{result.eval_case_id}</span>
<EvalStatusBadge status={outcome} />
</div>

<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
Assertion · <span className="font-mono normal-case text-foreground">{result.assertion.kind}</span>
</p>

<div className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">Expected</p>
<pre className="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-2 font-mono text-xs text-foreground">
{expectedDisplay}
</pre>
</div>
<div className="flex flex-col gap-1">
<p className="text-xs text-muted-foreground">Actual</p>
{isCompleted ? (
<pre className="whitespace-pre-wrap break-words rounded-md bg-muted/40 p-2 font-mono text-xs text-foreground">
{result.response_text ?? "—"}
</pre>
) : (
<p className="rounded-md bg-muted/40 p-2 text-xs italic text-muted-foreground">
No response — case {result.status}
</p>
)}
</div>
</div>

{result.reason ? (
<p className="text-sm text-destructive-text">{result.reason}</p>
) : null}
</li>
);
}
48 changes: 48 additions & 0 deletions apps/dashboard/components/evals/EvalStatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* EvalStatusBadge — evals-console TASK.md §3 CONTRACT M4. A single status vocabulary
* shared by both worlds this console renders: a RUN's own status
* ("pending"|"running"|"completed"|"failed") and a CASE's DERIVED outcome
* ("pass"|"fail"|"refused"|"errored"|"pending" — see deriveCaseOutcome in
* CaseDiffRow.tsx). Every state carries its OWN icon + text label, never color alone
* (WCAG 1.4.1) — this is the "never a bare colored dot" requirement.
*/

import { AlertTriangle, CheckCircle2, Clock, HelpCircle, Loader2, ShieldOff, XCircle } from "lucide-react";
import { Badge, type BadgeProps } from "@/components/ui";
import { cn } from "@/lib/cn";

interface StatusConfig {
label: string;
variant: NonNullable<BadgeProps["variant"]>;
Icon: typeof CheckCircle2;
spin?: boolean;
}

const STATUS_CONFIG: Record<string, StatusConfig> = {
// derived case outcomes
pass: { label: "Pass", variant: "success", Icon: CheckCircle2 },
fail: { label: "Fail", variant: "destructive", Icon: XCircle },
refused: { label: "Refused", variant: "warning", Icon: ShieldOff },
errored: { label: "Errored", variant: "destructive", Icon: AlertTriangle },
pending: { label: "Pending", variant: "outline", Icon: Clock },
// run statuses
completed: { label: "Completed", variant: "success", Icon: CheckCircle2 },
running: { label: "Running", variant: "outline", Icon: Loader2, spin: true },
failed: { label: "Failed", variant: "destructive", Icon: XCircle },
};

export interface EvalStatusBadgeProps {
status: string;
className?: string;
}

export function EvalStatusBadge({ status, className }: EvalStatusBadgeProps) {
const config = STATUS_CONFIG[status] ?? { label: status, variant: "outline" as const, Icon: HelpCircle };
const { label, variant, Icon, spin } = config;
return (
<Badge variant={variant} className={cn("gap-1", className)}>
<Icon className={cn("size-3", spin && "animate-spin")} aria-hidden="true" />
{label}
</Badge>
);
}
109 changes: 109 additions & 0 deletions apps/dashboard/components/evals/EvalsListPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
"use client";

/**
* EvalsListPage — evals-console TASK.md §3 CONTRACT M1/M5/M6. Top of the verdict-first
* IA: Sets list -> Set detail -> Run verdict. This is the READ-focused console: sets and
* cases are authored via the /v1 API, so there is no in-console create — the empty state
* points the operator to the API instead (E4). GET /admin/evals/sets is NOT cursor-
* paginated (design_requirements) — the full returned list renders, no pagination stack.
*
* M6 identity gate: see RunVerdictPage's own docblock — identical reasoning, applied
* here to the sets query.
*/

import { useRouter } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { bffGet } from "@/lib/bff-client";
import { Empty, ErrorState, Loading, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui";
import { PageHeader } from "@/components/ui/page-header";
import { useCurrentUser } from "@/lib/hooks/use-current-user";
import { formatEpochSeconds } from "./format";
import { getEvalsErrorTitle } from "./errors";
import type { EvalSetsListResponse } from "./types";

const SUBSYSTEM = "Evals";
const EVAL_SETS_QUERY_KEY = ["eval-sets"];

export function EvalsListPage() {
const router = useRouter();
const { data: currentUser, isLoading: identityLoading } = useCurrentUser();
const identityReady = !identityLoading && currentUser !== null;

const setsQuery = useQuery<EvalSetsListResponse>({
queryKey: EVAL_SETS_QUERY_KEY,
queryFn: () => bffGet<EvalSetsListResponse>("/admin/evals/sets"),
retry: false,
enabled: identityReady,
});

// M6 fail-closed identity gate — a visible Loading indicator, never a null-leak.
if (!identityReady) {
return <Loading label="Loading…" />;
}

const items = setsQuery.data?.data ?? [];

return (
<section aria-labelledby="evals-heading" className="flex flex-col gap-6">
<PageHeader
title="Evals"
titleId="evals-heading"
description="Regression gates for your model configuration — verdict-first, with a per-case diff on drill-down."
/>

<div className="rounded-lg border border-border bg-muted/20 p-3 text-sm text-muted-foreground">
Eval sets, cases, and runs are managed via the <code className="font-mono">/v1</code> API — this
console reads the verdict and pins a baseline, it never authors sets or starts a run.
</div>

{setsQuery.isLoading ? (
<Loading label="Loading eval sets…" />
) : setsQuery.isError ? (
<ErrorState title={getEvalsErrorTitle(setsQuery.error, SUBSYSTEM)} onRetry={() => setsQuery.refetch()} />
) : items.length === 0 ? (
<Empty
title="No eval sets yet"
description="Create an eval set and add cases through the /v1 API (POST /v1/evals/sets), then launch a run — its verdict shows up here."
/>
) : (
<Table aria-label="Eval sets">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead className="text-right">Cases</TableHead>
<TableHead>Created</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((set) => (
<TableRow
key={set.id}
tabIndex={0}
aria-label={`Eval set ${set.name} · ${set.case_count} cases`}
onClick={() => router.push(`/app/evals/${set.id}`)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
router.push(`/app/evals/${set.id}`);
}
}}
className="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<TableCell>
<div className="flex flex-col">
<span className="font-medium text-foreground">{set.name}</span>
{set.description ? (
<span className="text-xs text-muted-foreground">{set.description}</span>
) : null}
</div>
</TableCell>
<TableCell className="text-right font-mono tabular-nums">{set.case_count}</TableCell>
<TableCell>{formatEpochSeconds(set.created_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</section>
);
}
Loading
Loading