Repository: promptfoo/promptfoo
Pull request #3554: feat(evals): Adds evals index page
Author: login: will-holley
Created: 2025-03-31T18:34:44Z | Merged: 2025-03-31T20:24:30Z

## Features

<img width="1724" alt="image" src="https://github.com/user-attachments/assets/2208305f-6d12-43ab-be06-d9628082e145" />

## Changes

Use the shared data grid component for the eval selector.

<img width="1727" alt="image" src="https://github.com/user-attachments/assets/78d43e32-b14d-4620-b89b-a335bfcc6aea" />




Diff:
diff --git a/package-lock.json b/package-lock.json
index c4e0311e4c38..c5de1a99c289 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11930,6 +11930,66 @@
         "robust-predicates": "^3.0.2"
       }
     },
+    "node_modules/@mui/x-data-grid": {
+      "version": "7.28.2",
+      "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.28.2.tgz",
+      "integrity": "sha512-ac/CPZ/zOeHjCvv3LwTUSFy+dofELP/Cl2nXLYWrqVHUgFAkkWxhk85kiI/N1G+Rf4WiKw+oJfeyGe8ZSeY4tg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
+        "@mui/x-internals": "7.28.0",
+        "clsx": "^2.1.1",
+        "prop-types": "^15.8.1",
+        "reselect": "^5.1.1",
+        "use-sync-external-store": "^1.0.0"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui-org"
+      },
+      "peerDependencies": {
+        "@emotion/react": "^11.9.0",
+        "@emotion/styled": "^11.8.1",
+        "@mui/material": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
+        "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta",
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0",
+        "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      },
+      "peerDependenciesMeta": {
+        "@emotion/react": {
+          "optional": true
+        },
+        "@emotion/styled": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@mui/x-data-grid/node_modules/@mui/x-internals": {
+      "version": "7.28.0",
+      "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.28.0.tgz",
+      "integrity": "sha512-p4GEp/09bLDumktdIMiw+OF4p+pJOOjTG0VUvzNxjbHB9GxbBKoMcHrmyrURqoBnQpWIeFnN/QAoLMFSpfwQbw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@babel/runtime": "^7.25.7",
+        "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0 || ^7.0.0-beta"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/mui-org"
+      },
+      "peerDependencies": {
+        "react": "^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/@mui/x-internals": {
       "version": "7.26.0",
       "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.26.0.tgz",
@@ -38210,6 +38270,13 @@
       "integrity": "sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==",
       "license": "MIT"
     },
+    "node_modules/reselect": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+      "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
+      "dev": true,
+      "license": "MIT"
+    },
     "node_modules/resolve": {
       "version": "1.22.10",
       "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -42229,6 +42296,16 @@
         "react": "*"
       }
     },
+    "node_modules/use-sync-external-store": {
+      "version": "1.5.0",
+      "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
+      "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+      "dev": true,
+      "license": "MIT",
+      "peerDependencies": {
+        "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+      }
+    },
     "node_modules/util": {
       "version": "0.12.5",
       "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz",
@@ -44368,6 +44445,7 @@
         "@mui/material": "^6.1.10",
         "@mui/system": "^6.1.10",
         "@mui/x-charts": "^7.23.1",
+        "@mui/x-data-grid": "^7.28.2",
         "@tanstack/react-table": "^8.20.6",
         "@testing-library/dom": "^10.4.0",
         "@testing-library/jest-dom": "^6.6.3",
diff --git a/src/app/package.json b/src/app/package.json
index f09fd483dcc2..5f008451fe44 100644
--- a/src/app/package.json
+++ b/src/app/package.json
@@ -22,6 +22,7 @@
     "@mui/material": "^6.1.10",
     "@mui/system": "^6.1.10",
     "@mui/x-charts": "^7.23.1",
+    "@mui/x-data-grid": "^7.28.2",
     "@tanstack/react-table": "^8.20.6",
     "@testing-library/dom": "^10.4.0",
     "@testing-library/jest-dom": "^6.6.3",
diff --git a/src/app/src/App.tsx b/src/app/src/App.tsx
index 3ce23bbc0d71..12c951fefb5e 100644
--- a/src/app/src/App.tsx
+++ b/src/app/src/App.tsx
@@ -14,6 +14,7 @@ import { useTelemetry } from './hooks/useTelemetry';
 import DatasetsPage from './pages/datasets/page';
 import EvalCreatorPage from './pages/eval-creator/page';
 import EvalPage from './pages/eval/page';
+import EvalsIndexPage from './pages/evals/page';
 import HistoryPage from './pages/history/page';
 import LauncherPage from './pages/launcher/page';
 import LoginPage from './pages/login';
@@ -53,6 +54,7 @@ const router = createBrowserRouter(
           />
           <Route path="/datasets" element={<DatasetsPage />} />
           <Route path="/eval" element={<EvalPage />} />
+          <Route path="/evals" element={<EvalsIndexPage />} />
           <Route path="/eval/:evalId" element={<EvalPage />} />
 
           {/* Redirect legacy /progress route to /history (since v0.104.5) */}
diff --git a/src/app/src/components/Navigation.tsx b/src/app/src/components/Navigation.tsx
index 601634de4b88..24464f8a53fd 100644
--- a/src/app/src/components/Navigation.tsx
+++ b/src/app/src/components/Navigation.tsx
@@ -59,7 +59,7 @@ function NavLink({ href, label }: { href: string; label: string }) {
   );
 }
 
-function Dropdown() {
+function CreateDropdown() {
   const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
   const open = Boolean(anchorEl);
   const location = useLocation();
@@ -109,6 +109,42 @@ function Dropdown() {
   );
 }
 
+function EvalsDropdown() {
+  const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
+  const open = Boolean(anchorEl);
+  const location = useLocation();
+
+  const handleClick = (event: React.MouseEvent<HTMLElement>) => {
+    setAnchorEl(event.currentTarget);
+  };
+
+  const handleClose = () => {
+    setAnchorEl(null);
+  };
+
+  const isActive = ['/eval', '/evals'].some((route) => location.pathname.startsWith(route));
+
+  return (
+    <>
+      <NavButton
+        onClick={handleClick}
+        endIcon={<ArrowDropDownIcon />}
+        className={isActive ? 'active' : ''}
+      >
+        Evals
+      </NavButton>
+      <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
+        <MenuItem onClick={handleClose} component={Link} to="/eval">
+          Latest Eval
+        </MenuItem>
+        <MenuItem onClick={handleClose} component={Link} to="/evals">
+          All Evals
+        </MenuItem>
+      </Menu>
+    </>
+  );
+}
+
 export default function Navigation({
   darkMode,
   onToggleDarkMode,
@@ -128,8 +164,8 @@ export default function Navigation({
         <NavToolbar>
           <NavSection>
             <Logo />
-            <Dropdown />
-            <NavLink href="/eval" label="Evals" />
+            <CreateDropdown />
+            <EvalsDropdown />
             <NavLink href="/prompts" label="Prompts" />
             <NavLink href="/datasets" label="Datasets" />
             <NavLink href="/history" label="History" />
diff --git a/src/app/src/pages/eval/components/CompareEvalMenuItem.tsx b/src/app/src/pages/eval/components/CompareEvalMenuItem.tsx
index a023b7e6e6e4..1f189fe188d9 100644
--- a/src/app/src/pages/eval/components/CompareEvalMenuItem.tsx
+++ b/src/app/src/pages/eval/components/CompareEvalMenuItem.tsx
@@ -1,5 +1,4 @@
-import React, { useState } from 'react';
-import { callApi } from '@app/utils/api';
+import { useState } from 'react';
 import CompareIcon from '@mui/icons-material/Compare';
 import ListItemIcon from '@mui/material/ListItemIcon';
 import ListItemText from '@mui/material/ListItemText';
@@ -14,40 +13,11 @@ interface CompareEvalMenuItemProps {
   onComparisonEvalSelected: (evalId: string) => void;
 }
 
-function CompareEvalMenuItem({ initialEvals, onComparisonEvalSelected }: CompareEvalMenuItemProps) {
+function CompareEvalMenuItem({ onComparisonEvalSelected }: CompareEvalMenuItemProps) {
   const { evalId: currentEvalId } = useStore();
   const [dialogOpen, setDialogOpen] = useState(false);
-  const [recentEvals, setRecentEvals] = useState<ResultLightweightWithLabel[]>(initialEvals);
-
-  const fetchRecentEvals = async () => {
-    try {
-      // First, get the dataset for the currentEvalId
-      // TODO(ian): In theory we already have this datasetId because we've fetched the current eval...dataset should probably be stored in the store
-      const fetchEvalId = currentEvalId || initialEvals[0].evalId;
-      const currentEvalResponse = await callApi(`/results/${fetchEvalId}`, {
-        cache: 'no-store',
-      });
-      const currentEvalData = await currentEvalResponse.json();
-      const datasetId = currentEvalData.data.datasetId;
-
-      if (!datasetId) {
-        console.error('No datasetId found for current eval ' + fetchEvalId);
-        return;
-      }
-
-      // Then, fetch the results with the obtained datasetId
-      const response = await callApi(`/results?datasetId=${datasetId}`, {
-        cache: 'no-store',
-      });
-      const body = (await response.json()) as { data: ResultLightweightWithLabel[] };
-      setRecentEvals(body.data.filter((_eval) => _eval.evalId !== fetchEvalId));
-    } catch (error) {
-      console.error('Error fetching recent evals:', error);
-    }
-  };
 
   const handleOpenDialog = () => {
-    fetchRecentEvals();
     setDialogOpen(true);
   };
 
@@ -73,10 +43,12 @@ function CompareEvalMenuItem({ initialEvals, onComparisonEvalSelected }: Compare
       <EvalSelectorDialog
         open={dialogOpen}
         onClose={handleCloseDialog}
-        recentEvals={recentEvals}
-        onRecentEvalSelected={handleEvalSelected}
+        onEvalSelected={(evalId) => {
+          handleEvalSelected(evalId);
+        }}
         title="Select an eval to compare"
         description="Only evals with the same dataset can be compared."
+        focusedEvalId={currentEvalId ?? undefined}
       />
     </>
   );
diff --git a/src/app/src/pages/eval/components/EvalSelectorDialog.tsx b/src/app/src/pages/eval/components/EvalSelectorDialog.tsx
index e47ec7950c47..b2805408fedb 100644
--- a/src/app/src/pages/eval/components/EvalSelectorDialog.tsx
+++ b/src/app/src/pages/eval/components/EvalSelectorDialog.tsx
@@ -1,237 +1,30 @@
-import React, { useState } from 'react';
-import ClearIcon from '@mui/icons-material/Clear';
 import Box from '@mui/material/Box';
 import Button from '@mui/material/Button';
 import Dialog from '@mui/material/Dialog';
 import DialogActions from '@mui/material/DialogActions';
 import DialogContent from '@mui/material/DialogContent';
 import DialogTitle from '@mui/material/DialogTitle';
-import IconButton from '@mui/material/IconButton';
-import InputAdornment from '@mui/material/InputAdornment';
-import Paper from '@mui/material/Paper';
-import Table from '@mui/material/Table';
-import TableBody from '@mui/material/TableBody';
-import TableCell from '@mui/material/TableCell';
-import TableContainer from '@mui/material/TableContainer';
-import TableHead from '@mui/material/TableHead';
-import TablePagination from '@mui/material/TablePagination';
-import TableRow from '@mui/material/TableRow';
-import TextField from '@mui/material/TextField';
-import Typography from '@mui/material/Typography';
-import fuzzysearch from 'fuzzysearch';
-import type { ResultLightweightWithLabel } from './types';
+import EvalsDataGrid from '../../evals/components/EvalsDataGrid';
 
-const ROWS_PER_PAGE = 50;
-
-interface EvalSelectorDialogProps {
+const EvalSelectorDialog: React.FC<{
   open: boolean;
   onClose: () => void;
-  recentEvals: ResultLightweightWithLabel[];
-  onRecentEvalSelected: (evalId: string) => void;
+  onEvalSelected: (evalId: string) => void;
   title?: string;
   description?: string;
-}
-
-const EvalSelectorDialog: React.FC<EvalSelectorDialogProps> = ({
-  open,
-  onClose,
-  recentEvals,
-  onRecentEvalSelected,
-  title,
-  description,
-}) => {
-  const [searchText, setSearchText] = useState<string>('');
-  const [focusedIndex, setFocusedIndex] = useState<number>(-1);
-  const [page, setPage] = useState(0);
-  const rowsPerPage = ROWS_PER_PAGE;
-  const searchInputRef = React.useRef<HTMLInputElement>(null);
-  const tableContainerRef = React.useRef<HTMLDivElement>(null);
-
-  const handleClose = () => {
-    onClose();
-    setSearchText('');
-    setFocusedIndex(-1);
-    setPage(0);
-  };
-
-  const filteredEvals = recentEvals.filter(
-    (_eval) =>
-      fuzzysearch(searchText.toLowerCase(), String(_eval.label).toLowerCase()) ||
-      (typeof _eval.description === 'string' &&
-        fuzzysearch(searchText.toLowerCase(), String(_eval.description).toLowerCase())),
-  );
-
-  const paginatedEvals = filteredEvals.slice(page * rowsPerPage, (page + 1) * rowsPerPage);
-
-  const handleSelectEval = (evalId: string) => {
-    onRecentEvalSelected(evalId);
-    handleClose();
-  };
-
-  const scrollToFocusedItem = React.useCallback(() => {
-    if (focusedIndex >= 0 && tableContainerRef.current) {
-      const tableRows = tableContainerRef.current.querySelectorAll('tbody tr');
-      const targetIndex = Math.min(focusedIndex + 3, tableRows.length - 1);
-      if (tableRows[targetIndex]) {
-        tableRows[targetIndex].scrollIntoView({
-          behavior: 'smooth',
-          block: 'nearest',
-        });
-      }
-    }
-  }, [focusedIndex]);
-
-  const handleKeyDown = (event: React.KeyboardEvent) => {
-    event.stopPropagation();
-    if (!open) {
-      return;
-    }
-
-    switch (event.key) {
-      case 'ArrowDown':
-        event.preventDefault();
-        setFocusedIndex((prevIndex) => Math.min(prevIndex + 1, filteredEvals.length - 1));
-        break;
-      case 'ArrowUp':
-        event.preventDefault();
-        setFocusedIndex((prevIndex) => Math.max(prevIndex - 1, 0));
-        break;
-      case 'Enter':
-        event.preventDefault();
-        if (focusedIndex >= 0 && focusedIndex < filteredEvals.length) {
-          handleSelectEval(filteredEvals[focusedIndex].evalId);
-        } else if (filteredEvals.length > 0) {
-          handleSelectEval(filteredEvals[0].evalId);
-        }
-        break;
-      case 'Escape':
-        event.preventDefault();
-        handleClose();
-        break;
-    }
-  };
-
-  const handleChangePage = (event: unknown, newPage: number) => {
-    setPage(newPage);
-    const newIndex = newPage * rowsPerPage;
-    setFocusedIndex(Math.min(newIndex, filteredEvals.length - 1));
-  };
-
-  React.useEffect(() => {
-    scrollToFocusedItem();
-  }, [scrollToFocusedItem]);
-
-  React.useEffect(() => {
-    if (open) {
-      setFocusedIndex(0);
-      setTimeout(() => {
-        searchInputRef.current?.focus();
-      }, 0);
-    }
-  }, [open]);
-
-  const dialogId = React.useId();
+  focusedEvalId?: string;
+}> = ({ open, onClose, onEvalSelected, title, description, focusedEvalId }) => {
   return (
-    <Dialog open={open} onClose={handleClose} maxWidth="md" fullWidth>
+    <Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
       {title ? <DialogTitle>{title}</DialogTitle> : null}
       <DialogContent>
         {description ? <Box sx={{ mb: 4 }}>{description}</Box> : null}
         <Box sx={{ width: '100%', mt: 2 }}>
-          <TextField
-            fullWidth
-            variant="outlined"
-            placeholder="Search"
-            value={searchText}
-            onChange={(e) => {
-              setSearchText(e.target.value);
-              setFocusedIndex(0);
-            }}
-            onKeyDown={handleKeyDown}
-            sx={{ mb: 2 }}
-            inputRef={searchInputRef}
-            id={`eval-selector-search-${dialogId}`}
-            InputProps={{
-              endAdornment: searchText ? (
-                <InputAdornment position="end">
-                  <IconButton
-                    aria-label="clear search"
-                    onClick={() => {
-                      setSearchText('');
-                      setFocusedIndex(0);
-                      searchInputRef.current?.focus();
-                    }}
-                    edge="end"
-                    size="small"
-                  >
-                    <ClearIcon />
-                  </IconButton>
-                </InputAdornment>
-              ) : null,
-            }}
-          />
-          <TableContainer
-            component={Paper}
-            sx={{ height: '600px', overflow: 'auto' }}
-            ref={tableContainerRef}
-          >
-            <Table stickyHeader>
-              <TableHead>
-                <TableRow>
-                  <TableCell>Created</TableCell>
-                  <TableCell>Description</TableCell>
-                  <TableCell># Tests</TableCell>
-                </TableRow>
-              </TableHead>
-              <TableBody>
-                {paginatedEvals.length > 0 ? (
-                  paginatedEvals.map((_eval, index) => (
-                    <TableRow
-                      key={_eval.evalId}
-                      hover
-                      onClick={() => handleSelectEval(_eval.evalId)}
-                      sx={{
-                        cursor: 'pointer',
-                        backgroundColor:
-                          index === focusedIndex ? 'rgba(255, 255, 0, 0.1)' : 'inherit',
-                      }}
-                    >
-                      <TableCell>{new Date(_eval.createdAt).toLocaleString()}</TableCell>
-                      <TableCell>{_eval.description || _eval.label}</TableCell>
-                      <TableCell>{_eval.numTests}</TableCell>
-                    </TableRow>
-                  ))
-                ) : (
-                  <TableRow>
-                    <TableCell colSpan={3} align="center" sx={{ py: 4 }}>
-                      <Box sx={{ textAlign: 'center', color: 'text.secondary' }}>
-                        <Box sx={{ fontSize: '3rem', mb: 2 }}>🔍</Box>
-                        <Typography variant="h6" gutterBottom>
-                          No evaluations found
-                        </Typography>
-                        <Typography variant="body2">
-                          Try adjusting your search or create a new evaluation
-                        </Typography>
-                      </Box>
-                    </TableCell>
-                  </TableRow>
-                )}
-              </TableBody>
-            </Table>
-          </TableContainer>
-          {filteredEvals.length > rowsPerPage && (
-            <TablePagination
-              component="div"
-              count={filteredEvals.length}
-              page={page}
-              onPageChange={handleChangePage}
-              rowsPerPage={rowsPerPage}
-              rowsPerPageOptions={[ROWS_PER_PAGE]}
-            />
-          )}
+          <EvalsDataGrid onEvalSelected={onEvalSelected} focusedEvalId={focusedEvalId} />
         </Box>
       </DialogContent>
       <DialogActions>
-        <Button onClick={handleClose}>Cancel</Button>
+        <Button onClick={onClose}>Cancel</Button>
       </DialogActions>
     </Dialog>
   );
diff --git a/src/app/src/pages/eval/components/EvalSelectorKeyboardShortcut.tsx b/src/app/src/pages/eval/components/EvalSelectorKeyboardShortcut.tsx
index a7148272a17a..cb6d020d360a 100644
--- a/src/app/src/pages/eval/components/EvalSelectorKeyboardShortcut.tsx
+++ b/src/app/src/pages/eval/components/EvalSelectorKeyboardShortcut.tsx
@@ -1,13 +1,11 @@
 import React, { useState } from 'react';
 import EvalSelectorDialog from './EvalSelectorDialog';
-import type { ResultLightweightWithLabel } from './types';
 
 interface EvalSelectorProps {
-  recentEvals: ResultLightweightWithLabel[];
-  onRecentEvalSelected: (evalId: string) => void;
+  onEvalSelected: (evalId: string) => void;
 }
 
-const EvalSelector: React.FC<EvalSelectorProps> = ({ recentEvals, onRecentEvalSelected }) => {
+const EvalSelector: React.FC<EvalSelectorProps> = ({ onEvalSelected }) => {
   const [open, setOpen] = useState(false);
   //const isMac =
   //  typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.userAgent);
@@ -41,8 +39,7 @@ const EvalSelector: React.FC<EvalSelectorProps> = ({ recentEvals, onRecentEvalSe
       title="Open an Eval"
       open={open}
       onClose={handleClose}
-      recentEvals={recentEvals}
-      onRecentEvalSelected={onRecentEvalSelected}
+      onEvalSelected={onEvalSelected}
     />
   );
 };
diff --git a/src/app/src/pages/eval/components/ResultsView.tsx b/src/app/src/pages/eval/components/ResultsView.tsx
index 363abf874cbc..1c1aca5de7a4 100644
--- a/src/app/src/pages/eval/components/ResultsView.tsx
+++ b/src/app/src/pages/eval/components/ResultsView.tsx
@@ -509,9 +509,12 @@ export default function ResultsView({
           <EvalSelectorDialog
             open={evalSelectorDialogOpen}
             onClose={() => setEvalSelectorDialogOpen(false)}
-            recentEvals={recentEvals}
-            onRecentEvalSelected={onRecentEvalSelected}
+            onEvalSelected={(evalId) => {
+              setEvalSelectorDialogOpen(false);
+              onRecentEvalSelected(evalId);
+            }}
             title="Select an Eval"
+            focusedEvalId={evalId ?? undefined}
           />
         </Box>
         {evalId && <EvalIdChip evalId={evalId} onCopy={handleEvalIdCopyClick} />}
@@ -669,10 +672,7 @@ export default function ResultsView({
         onShare={handleShare}
       />
       <SettingsModal open={viewSettingsModalOpen} onClose={() => setViewSettingsModalOpen(false)} />
-      <EvalSelectorKeyboardShortcut
-        recentEvals={recentEvals}
-        onRecentEvalSelected={onRecentEvalSelected}
-      />
+      <EvalSelectorKeyboardShortcut onEvalSelected={onRecentEvalSelected} />
     </div>
   );
 }
diff --git a/src/app/src/pages/evals/components/EvalsDataGrid.tsx b/src/app/src/pages/evals/components/EvalsDataGrid.tsx
new file mode 100644
index 000000000000..28480e837e08
--- /dev/null
+++ b/src/app/src/pages/evals/components/EvalsDataGrid.tsx
@@ -0,0 +1,232 @@
+import { useMemo, useEffect, useState } from 'react';
+import { callApi } from '@app/utils/api';
+import { Box, Typography, Paper, CircularProgress, useTheme } from '@mui/material';
+import {
+  DataGrid,
+  type GridColDef,
+  GridToolbarContainer,
+  GridToolbarColumnsButton,
+  GridToolbarFilterButton,
+  GridToolbarDensitySelector,
+  GridToolbarExport,
+  GridToolbarQuickFilter,
+  type GridRowSelectionModel,
+} from '@mui/x-data-grid';
+
+type Eval = {
+  createdAt: number;
+  datasetId: string;
+  description: string | null;
+  evalId: string;
+  isRedteam: number;
+  label: string;
+  numTests: number;
+};
+
+// augment the props for the toolbar slot
+declare module '@mui/x-data-grid' {
+  interface ToolbarPropsOverrides {
+    showUtilityButtons: boolean;
+  }
+}
+
+function CustomToolbar({ showUtilityButtons }: { showUtilityButtons: boolean }) {
+  const theme = useTheme();
+  return (
+    <GridToolbarContainer sx={{ p: 1, borderBottom: `1px solid ${theme.palette.divider}` }}>
+      {showUtilityButtons && (
+        <Box sx={{ display: 'flex', gap: 1 }}>
+          <GridToolbarColumnsButton />
+          <GridToolbarFilterButton />
+          <GridToolbarDensitySelector />
+          <GridToolbarExport />
+        </Box>
+      )}
+      <Box sx={{ flexGrow: 1 }} />
+      <GridToolbarQuickFilter
+        sx={{
+          '& .MuiInputBase-root': {
+            borderRadius: 2,
+            backgroundColor: theme.palette.background.paper,
+          },
+        }}
+      />
+    </GridToolbarContainer>
+  );
+}
+
+/**
+ * Displays a list of evals.
+ *
+ * @param onEvalSelected - Callback to handle when an eval is selected (via clicking on its id cell).
+ * @param focusedEvalId - An optional ID of the eval to focus when the grid loads.
+ */
+export default function EvalsDataGrid({
+  onEvalSelected,
+  focusedEvalId,
+  showUtilityButtons = false,
+}: {
+  onEvalSelected: (evalId: string) => void;
+  focusedEvalId?: string;
+  showUtilityButtons?: boolean;
+}) {
+  const [evals, setEvals] = useState<Eval[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>(
+    focusedEvalId ? [focusedEvalId] : [],
+  );
+
+  /**
+   * Fetch evals from the API.
+   */
+  useEffect(() => {
+    const fetchEvals = async () => {
+      try {
+        const response = await callApi('/results', { cache: 'no-store' });
+        if (!response.ok) {
+          throw new Error('Failed to fetch evals');
+        }
+        const body = (await response.json()) as { data: Eval[] };
+        setEvals(body.data);
+      } catch (error) {
+        setError(error as Error);
+      } finally {
+        setIsLoading(false);
+      }
+    };
+    fetchEvals();
+  }, []);
+
+  const handleCellClick = (params: any) => onEvalSelected(params.row.evalId);
+
+  const columns: GridColDef<Eval>[] = useMemo(
+    () =>
+      [
+        {
+          field: 'evalId',
+          headerName: 'ID',
+          flex: 1,
+        },
+        {
+          field: 'createdAt',
+          headerName: 'Created',
+          flex: 1,
+          valueFormatter: (value: Eval['createdAt']) => new Date(value).toLocaleString(),
+        },
+        {
+          field: 'description',
+          headerName: 'Description',
+          flex: 1,
+          valueGetter: (value: Eval['description'], row: Eval) => value ?? row.label,
+        },
+        {
+          field: 'numTests',
+          headerName: '# Tests',
+          flex: 1,
+        },
+      ].filter(Boolean) as GridColDef<Eval>[],
+    [],
+  );
+
+  return (
+    <Paper elevation={2} sx={{ height: '100%', overflow: 'hidden' }}>
+      <DataGrid
+        rows={evals}
+        columns={columns}
+        loading={isLoading}
+        getRowId={(row) => row.evalId}
+        slots={{
+          toolbar: CustomToolbar,
+          loadingOverlay: () => (
+            <Box
+              sx={{
+                display: 'flex',
+                flexDirection: 'column',
+                alignItems: 'center',
+                justifyContent: 'center',
+                height: '100%',
+                gap: 2,
+              }}
+            >
+              <CircularProgress />
+              <Typography variant="body2" color="text.secondary">
+                Loading evaluations...
+              </Typography>
+            </Box>
+          ),
+          noRowsOverlay: () => (
+            <Box
+              sx={{
+                textAlign: 'center',
+                color: 'text.secondary',
+                height: '100%',
+                display: 'flex',
+                flexDirection: 'column',
+                justifyContent: 'center',
+                alignItems: 'center',
+                p: 3,
+              }}
+            >
+              {error ? (
+                <>
+                  <Box sx={{ fontSize: '2rem', mb: 2 }}>⚠️</Box>
+                  <Typography variant="h6" gutterBottom color="error">
+                    Error loading evals
+                  </Typography>
+                  <Typography variant="body2" color="text.secondary">
+                    {error.message}
+                  </Typography>
+                </>
+              ) : (
+                <>
+                  <Box sx={{ fontSize: '2rem', mb: 2 }}>🔍</Box>
+                  <Typography variant="h6" gutterBottom>
+                    No evals found
+                  </Typography>
+                  <Typography variant="body2" color="text.secondary">
+                    Try adjusting your search or create a new evaluation
+                  </Typography>
+                </>
+              )}
+            </Box>
+          ),
+        }}
+        slotProps={{ toolbar: { showUtilityButtons } }}
+        onCellClick={handleCellClick}
+        sx={{
+          border: 'none',
+          '& .MuiDataGrid-row': {
+            cursor: 'pointer',
+            transition: 'background-color 0.2s ease',
+            '&:hover': {
+              backgroundColor: 'action.hover',
+            },
+          },
+          '& .MuiDataGrid-cell': {
+            borderColor: 'divider',
+          },
+          '& .MuiDataGrid-columnHeaders': {
+            backgroundColor: 'background.default',
+            borderColor: 'divider',
+          },
+          '& .MuiDataGrid-selectedRow': {
+            backgroundColor: 'action.selected',
+          },
+        }}
+        onRowSelectionModelChange={setRowSelectionModel}
+        rowSelectionModel={rowSelectionModel}
+        initialState={{
+          sorting: {
+            sortModel: [{ field: 'createdAt', sort: 'desc' }],
+          },
+          pagination: {
+            paginationModel: { pageSize: 25 },
+          },
+        }}
+        pageSizeOptions={[10, 25, 50, 100]}
+      />
+    </Paper>
+  );
+}
diff --git a/src/app/src/pages/evals/page.tsx b/src/app/src/pages/evals/page.tsx
new file mode 100644
index 000000000000..bea969f216f2
--- /dev/null
+++ b/src/app/src/pages/evals/page.tsx
@@ -0,0 +1,28 @@
+import { useEffect } from 'react';
+import { useNavigate } from 'react-router-dom';
+import { Container, Paper, Typography, Box } from '@mui/material';
+import EvalsDataGrid from './components/EvalsDataGrid';
+
+export default function EvalsIndexPage() {
+  const navigate = useNavigate();
+
+  useEffect(() => {
+    document.title = 'Evals | promptfoo';
+  }, []);
+
+  return (
+    <Container maxWidth="xl" sx={{ py: 4, height: '100%' }}>
+      <Typography variant="h4" gutterBottom>
+        Evals
+      </Typography>
+      <Box sx={{ height: '100%' }}>
+        <Paper sx={{ height: '100%' }}>
+          <EvalsDataGrid
+            onEvalSelected={(evalId) => navigate(`/eval/${evalId}`)}
+            showUtilityButtons
+          />
+        </Paper>
+      </Box>
+    </Container>
+  );
+}


Reviews:
user: login: mldangelo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T18:40:35Z
user: login: ellipsis-dev[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T18:41:35Z
user: login: mldangelo
body: This looks really good! Consider asking the llm nicely to make it prettier. 

One note on changing the navbar link. 

Are we also going to update prompts, history, and datasets pages (doesn't have to be in this PR)
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T18:49:45Z
user: login: will-holley
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T19:02:41Z
user: login: mldangelo
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-31T19:56:30Z

Review comments:
diff_hunk: @@ -129,7 +129,7 @@ export default function Navigation({
           <NavSection>
             <Logo />
             <Dropdown />
-            <NavLink href="/eval" label="Evals" />
+            <NavLink href="/evals" label="Evals" />
path: src/app/src/components/Navigation.tsx
user: login: mldangelo
body: Should think through the default behavior when we open promptfoo view and whether or want we want /eval to remain in the top nav. This may confuse people 
created_at: 2025-03-31T18:40:34Z
author_association: MEMBER
diff_hunk: @@ -73,10 +73,13 @@
       <EvalSelectorDialog
         open={dialogOpen}
         onClose={handleCloseDialog}
-        recentEvals={recentEvals}
-        onRecentEvalSelected={handleEvalSelected}
+        onEvalSelected={(evalId) => {
+          handleEvalSelected(evalId);
+          setDialogOpen(false);
path: src/app/src/pages/eval/components/CompareEvalMenuItem.tsx
user: login: ellipsis-dev[bot]
body: Remove redundant dialog closing; `handleEvalSelected` already calls `handleCloseDialog`.
```suggestion

```
created_at: 2025-03-31T18:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,177 @@
+import { useMemo, useEffect, useState } from 'react';
+import { callApi } from '@app/utils/api';
+import { Box, Typography } from '@mui/material';
+import {
+  DataGrid,
+  type GridColDef,
+  GridToolbarContainer,
+  GridToolbarColumnsButton,
+  GridToolbarFilterButton,
+  GridToolbarDensitySelector,
+  GridToolbarExport,
+  GridToolbarQuickFilter,
+  type GridRowSelectionModel,
+} from '@mui/x-data-grid';
+
+type Eval = {
+  createdAt: number;
+  datasetId: string;
+  description: string | null;
+  evalId: string;
+  isRedteam: number;
+  label: string;
+  numTests: number;
+};
+
+// augment the props for the toolbar slot
+declare module '@mui/x-data-grid' {
+  interface ToolbarPropsOverrides {
+    showUtilityButtons: boolean;
+  }
+}
+
+function CustomToolbar({ showUtilityButtons }: { showUtilityButtons: boolean }) {
+  return (
+    <GridToolbarContainer>
+      {showUtilityButtons && (
+        <>
+          <GridToolbarColumnsButton />
+          <GridToolbarFilterButton />
+          <GridToolbarDensitySelector />
+          <GridToolbarExport />
+        </>
+      )}
+      <Box sx={{ flexGrow: 1 }} />
+      <GridToolbarQuickFilter />
+    </GridToolbarContainer>
+  );
+}
+
+/**
+ * Displays a list of evals.
+ *
+ * @param onEvalSelected - Callback to handle when an eval is selected (via clicking on its id cell).
+ * @param focusedEvalId - An optional ID of the eval to focus when the grid loads.
+ */
+export default function EvalsDataGrid({
+  onEvalSelected,
+  focusedEvalId,
+  showUtilityButtons = false,
+}: {
+  onEvalSelected: (evalId: string) => void;
+  focusedEvalId?: string;
+  showUtilityButtons?: boolean;
+}) {
+  const [evals, setEvals] = useState<Eval[]>([]);
+  const [isLoading, setIsLoading] = useState(true);
+  const [error, setError] = useState<Error | null>(null);
+
+  const [rowSelectionModel, setRowSelectionModel] = useState<GridRowSelectionModel>(
path: src/app/src/pages/evals/components/EvalsDataGrid.tsx
user: login: ellipsis-dev[bot]
body: Consider syncing `rowSelectionModel` with changes in `focusedEvalId` via `useEffect` if it may update after mount.
created_at: 2025-03-31T18:41:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -167,6 +167,7 @@
     "@anthropic-ai/sdk": "^0.39.0",
     "@apidevtools/json-schema-ref-parser": "^11.9.3",
     "@googleapis/sheets": "^9.6.0",
+    "@mui/x-data-grid": "^7.28.2",
path: package.json
user: login: mldangelo
body: should be in src/app/package.json instead 
created_at: 2025-03-31T18:48:43Z
author_association: MEMBER
diff_hunk: @@ -129,7 +129,7 @@ export default function Navigation({
           <NavSection>
             <Logo />
             <Dropdown />
-            <NavLink href="/eval" label="Evals" />
+            <NavLink href="/evals" label="Evals" />
path: src/app/src/components/Navigation.tsx
user: login: will-holley
body: I think it makes sense that `promptfoo view` opens `http://localhost:15500/eval` which loads/renders the most recent eval.

Are you thinking that clicking "Evals" in the top nav and then clicking the first eval in the data grid in order to access the most recent eval isn't intuitive? The current design – where the user sees a detailed view of what the eval entails and how it performed – is definitely more information rich than the data grid.
created_at: 2025-03-31T19:02:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -129,7 +129,7 @@ export default function Navigation({
           <NavSection>
             <Logo />
             <Dropdown />
-            <NavLink href="/eval" label="Evals" />
+            <NavLink href="/evals" label="Evals" />
path: src/app/src/components/Navigation.tsx
user: login: mldangelo
body: I think we should make this a drop down that has the latest eval and the evals page if not adding a second button. 

Thinking through use cases - most people that run promptfoo locally will sit on /eval and we have the magic websocket auto refresh - I want to continue to have a link to that page. With the list everything is a hard link meaning selecting the most recent from the top of the list will not use the auto refresh behavior 
created_at: 2025-03-31T19:56:30Z
author_association: MEMBER

PR comments:
user: login: gru-agent[bot]
created_at: 2025-03-31T18:39:44Z
body: ## TestGru Assignment

### Summary

| Link | CommitId | Status | Reason |
| ---- | -------- | ------ | ------ |
| [Detail](https://gru.ai/:test/promptfoo@github/promptfoo/ca6d7e46-1009-4429-98d2-a5ea6b6c839f) | 0e6bfbf232764d9de8b521fdb2571d49b5cc475f | 🚫 Skipped | No files need to be tested {"package-lock.json":"target file(package-lock.json) not in work scope \n include: src/app/src/**/*.ts \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js","package.json":"target file(package.json) not in work scope \n include: src/app/src/**/*.ts \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js","src/app/src/App.tsx":"target file(src/app/src/App.tsx) not in work scope \n include: src/app/src/**/*.ts \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js","src/app/src/components/Navigation.tsx":"target file(src/app/src/components/Navigation.tsx) not in work scope \n include: src/app/src/**/*.ts \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js","src/app/src/pages/eval/components/CompareEvalMenuItem.tsx":"targe… |

> [!TIP]
> You can `@gru-agent` and leave your feedback. TestGru will make adjustments based on your input

author_association: CONTRIBUTOR
user: login: gru-agent[bot]
created_at: 2025-03-31T18:39:47Z
body: ## TestGru Assignment

### Summary

| Link | CommitId | Status | Reason |
| ---- | -------- | ------ | ------ |
| [Detail](https://gru.ai/:test/promptfoo@github/promptfoo/9b598a71-1cf1-4d9c-9131-78ab74c49174) | 0e6bfbf232764d9de8b521fdb2571d49b5cc475f | 🚫 Skipped | No files need to be tested {"package-lock.json":"target file(package-lock.json) not in work scope \n include: src \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js,src/app/src","package.json":"target file(package.json) not in work scope \n include: src \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js,src/app/src","src/app/src/App.tsx":"target file(src/app/src/App.tsx) not in work scope \n include: src \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js,src/app/src","src/app/src/components/Navigation.tsx":"target file(src/app/src/components/Navigation.tsx) not in work scope \n include: src \n exclude: node_modules,**/*.test.ts,**/*.test.tsx,**/*.spec.ts,**/*.spec.tsx,**/*.d.ts,**/*.test.js,**/*.spec.js,src/app/src","src/app/src/pages/eval/components/CompareEvalMenuItem.tsx":"target file(src/app/s… |

> [!TIP]
> You can `@gru-agent` and leave your feedback. TestGru will make adjustments based on your input

author_association: CONTRIBUTOR