diff --git a/frontend/README.md b/frontend/README.md
index ead30c2f00..690cce7374 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -21,6 +21,14 @@ refreshes do not override your open/closed choice. Creating, copying, or repeati
conversations still opens the desktop drawer. On narrow screens the drawer opens
only when requested and closes after selecting a conversation.
+## Dataset catalog
+
+The **Datasets** page shows summaries of datasets already loaded into the backend's
+memory. When memory is empty, **Learn how to load datasets into memory** opens the
+[dataset-loading guide](https://microsoft.github.io/PyRIT/latest/code/datasets/loading-datasets/).
+Use the same memory as the backend, then select **Refresh** in the catalog.
+Provider-loading controls and individual-example browsing are not available yet.
+
## Resuming a scenario run
Failed runs offer **Resume run** on the run page and **Resume** in Scanner History.
diff --git a/frontend/e2e/datasets.spec.ts b/frontend/e2e/datasets.spec.ts
new file mode 100644
index 0000000000..2471155c54
--- /dev/null
+++ b/frontend/e2e/datasets.spec.ts
@@ -0,0 +1,142 @@
+import { expect, test, type Page } from "./_fixtures";
+import { mockVersion } from "./_compatibility";
+import { READY_RUNTIME } from "./_runtime";
+
+const LONG_HARM_CATEGORY = "cybersecurity_credentials_and_privileged_information_disclosure";
+
+const DATASET = {
+ name: "harmbench",
+ selection_key: "dataset:named:harmbench",
+ loaded: true,
+ provider_available: true,
+ logical_examples: 12,
+ seed_pieces: 12,
+ objectives: 1,
+ modalities: ["text"],
+ harm_categories: [LONG_HARM_CATEGORY],
+ has_unlabeled_harm_categories: false,
+};
+
+const NEIGHBOR_DATASET = {
+ ...DATASET,
+ name: "neighbor",
+ selection_key: "dataset:named:neighbor",
+};
+
+async function mockDatasetCatalog(page: Page): Promise {
+ await page.route("**/api/**", async (route) => {
+ const apiPath = new URL(route.request().url()).pathname.replace(/^\/api/, "");
+ const body = (payload: unknown) => ({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(payload),
+ });
+
+ if (apiPath === "/datasets") {
+ await route.fulfill(body({ items: [DATASET, NEIGHBOR_DATASET] }));
+ return;
+ }
+ if (apiPath === "/runtime") {
+ await route.fulfill(body(READY_RUNTIME));
+ return;
+ }
+ if (apiPath === "/version") {
+ await route.fulfill(body(mockVersion()));
+ return;
+ }
+ if (apiPath === "/auth/config") {
+ await route.fulfill(body({ clientId: "", tenantId: "", allowedGroupIds: "" }));
+ return;
+ }
+ if (apiPath === "/auth/access") {
+ await route.fulfill(body({ isAdmin: false }));
+ return;
+ }
+ if (apiPath === "/health") {
+ await route.fulfill(body({ status: "healthy" }));
+ return;
+ }
+ if (apiPath === "/targets" || apiPath === "/converters" || apiPath === "/converters/types") {
+ await route.fulfill(body({ items: [] }));
+ return;
+ }
+ if (apiPath === "/targets/types") {
+ await route.fulfill(body({ items: [] }));
+ return;
+ }
+ if (apiPath === "/labels") {
+ await route.fulfill(body({ source: "attacks", labels: {} }));
+ return;
+ }
+
+ throw new Error(`Unhandled dataset catalog request: ${route.request().method()} ${apiPath}`);
+ });
+}
+
+async function expectLongLabelStaysInsideCard(page: Page): Promise {
+ const card = page.getByRole("article", { name: "harmbench" });
+ await expect(card).toBeVisible();
+ await expect(card.getByText(LONG_HARM_CATEGORY)).toBeVisible();
+
+ const metrics = await card.evaluate((article, label) => {
+ const badge = Array.from(article.querySelectorAll("*")).find((element) => element.textContent === label);
+ const textHost = badge ?? article;
+ const range = document.createRange();
+ range.selectNodeContents(textHost);
+ const text = range.getBoundingClientRect();
+ const cardBox = article.getBoundingClientRect();
+ const badgeBox = textHost.getBoundingClientRect();
+ return {
+ cardWidth: cardBox.width,
+ cardRight: cardBox.right,
+ cardLeft: cardBox.left,
+ cardTop: cardBox.top,
+ cardBottom: cardBox.bottom,
+ textLeft: text.left,
+ textRight: text.right,
+ textTop: text.top,
+ textBottom: text.bottom,
+ badgeHeight: badgeBox.height,
+ scrollWidth: textHost.scrollWidth,
+ clientWidth: textHost.clientWidth,
+ };
+ }, LONG_HARM_CATEGORY);
+
+ expect(metrics.cardWidth).toBeLessThanOrEqual(360);
+ expect(metrics.textLeft).toBeGreaterThanOrEqual(metrics.cardLeft - 1);
+ expect(metrics.textRight).toBeLessThanOrEqual(metrics.cardRight + 1);
+ expect(metrics.textTop).toBeGreaterThanOrEqual(metrics.cardTop - 1);
+ expect(metrics.textBottom).toBeLessThanOrEqual(metrics.cardBottom + 1);
+ expect(metrics.badgeHeight).toBeGreaterThan(20);
+ expect(metrics.scrollWidth).toBeLessThanOrEqual(metrics.clientWidth + 1);
+}
+
+test.describe("dataset catalog layout", () => {
+ test.beforeEach(async ({ page }) => {
+ await mockDatasetCatalog(page);
+ });
+
+ test("wraps a long harm category inside a multi-column desktop catalog", async ({ page }) => {
+ await page.setViewportSize({ width: 1440, height: 1050 });
+ await page.goto("/datasets");
+ await expectLongLabelStaysInsideCard(page);
+
+ const cards = page.getByRole("article");
+ await expect(cards).toHaveCount(2);
+ const [first, second] = await cards.evaluateAll((articles: Element[]) =>
+ articles.map((article: Element) => {
+ const { left, right, top } = article.getBoundingClientRect();
+ return { left, right, top };
+ }),
+ );
+ expect(first.top).toBe(second.top);
+ expect(second.left).toBeGreaterThan(first.right);
+ });
+
+ test("wraps a long harm category inside the card on a 390px mobile viewport", async ({ page }) => {
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.goto("/datasets");
+ await expectLongLabelStaysInsideCard(page);
+ expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(390);
+ });
+});
diff --git a/frontend/e2e/scenario-history.spec.ts b/frontend/e2e/scenario-history.spec.ts
index 1abb526004..0cfc087e49 100644
--- a/frontend/e2e/scenario-history.spec.ts
+++ b/frontend/e2e/scenario-history.spec.ts
@@ -535,13 +535,14 @@ test.describe("Scenario catalog, history, and live run routing", () => {
const primaryNavigation = page.getByRole("navigation", { name: "Primary" });
const primaryButtons = primaryNavigation.getByRole("button");
await expect(primaryNavigation.getByRole("button", { name: "Configuration" })).toBeVisible();
- await expect(primaryButtons).toHaveCount(6);
+ await expect(primaryButtons).toHaveCount(7);
expect(await primaryButtons.evaluateAll((buttons) =>
buttons.map((button) => button.getAttribute("aria-label")))).toEqual([
"Home",
"Chat",
"History",
"Scanner",
+ "Datasets",
"Registry",
"Configuration",
]);
diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts
index 6185c1251b..d75603ed57 100644
--- a/frontend/e2e/touch-targets.spec.ts
+++ b/frontend/e2e/touch-targets.spec.ts
@@ -374,6 +374,27 @@ async function installTouchTargetMocks(page: Page): Promise {
await route.fulfill(jsonResponse({ converter_types: [] }));
return;
}
+ if (apiPath === "/datasets") {
+ await route.fulfill(
+ jsonResponse({
+ items: [
+ {
+ name: "harmbench",
+ selection_key: "dataset:named:harmbench",
+ loaded: true,
+ provider_available: true,
+ logical_examples: 12,
+ seed_pieces: 12,
+ objectives: 1,
+ modalities: ["text"],
+ harm_categories: ["hate"],
+ has_unlabeled_harm_categories: false,
+ },
+ ],
+ })
+ );
+ return;
+ }
if (apiPath === "/scenarios/catalog" || apiPath === "/scenarios/runs") {
await route.fulfill(
jsonResponse({
@@ -751,6 +772,16 @@ test.describe("Mobile touch targets", () => {
await expectNoDocumentOverflow(page);
});
+ test("keeps closed dataset filters at least 44px", async ({ page }) => {
+ await page.goto("/datasets");
+ await expect(page.getByRole("heading", { name: "Datasets" })).toBeVisible();
+
+ await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Modalities" }));
+ await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Harm categories" }));
+ await expectMinimumTouchTarget(page.getByRole("combobox", { name: "Sort datasets" }));
+ await expectNoDocumentOverflow(page);
+ });
+
test("keeps every tour action at least 44px without changing containment", async ({
page,
}) => {
@@ -886,4 +917,10 @@ test("preserves compact desktop controls and existing sidebar dimensions", async
await openAttack.scrollIntoViewIfNeeded();
await expectCompactDesktopTarget(openAttack);
await expectCompactDesktopTarget(page.getByTestId("next-page-btn"));
+
+ await page.goto("/datasets");
+ await expect(page.getByRole("heading", { name: "Datasets" })).toBeVisible();
+ await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Modalities" }));
+ await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Harm categories" }));
+ await expectCompactDesktopTarget(page.getByRole("combobox", { name: "Sort datasets" }));
});
diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx
index f86bddcfbf..e856f9e0db 100644
--- a/frontend/src/App.test.tsx
+++ b/frontend/src/App.test.tsx
@@ -160,6 +160,9 @@ jest.mock("./components/Layout/MainLayout", () => {
onNavigate("scenarios")} data-testid="nav-scenarios">
Scenarios
+ onNavigate("datasets")} data-testid="nav-datasets">
+ Datasets
+
{children}
);
@@ -386,6 +389,33 @@ jest.mock("./components/Home/Home", () => {
};
});
+jest.mock("./components/Datasets/DatasetCatalog", () => {
+ const MockDatasetCatalog = () =>
;
+ MockDatasetCatalog.displayName = "MockDatasetCatalog";
+ return {
+ __esModule: true,
+ default: MockDatasetCatalog,
+ };
+});
+
+jest.mock("./components/Datasets/DatasetSelection", () => {
+ const { useLocation } = jest.requireActual("react-router");
+ const MockDatasetSelection = () => {
+ const location = useLocation();
+ return (
+
+ );
+ };
+ MockDatasetSelection.displayName = "MockDatasetSelection";
+ return {
+ __esModule: true,
+ default: MockDatasetSelection,
+ };
+});
+
jest.mock("./components/Scenarios/ScenarioCatalog", () => {
const MockScenarioCatalog = () =>
;
MockScenarioCatalog.displayName = "MockScenarioCatalog";
@@ -637,6 +667,41 @@ describe("App", () => {
expect(screen.getByTestId("attack-history")).toBeInTheDocument();
});
+ it("renders the dataset catalog when deep-linked to /datasets", () => {
+ renderApp("/datasets");
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "datasets"
+ );
+ expect(screen.getByTestId("dataset-catalog")).toBeInTheDocument();
+ });
+
+ it("keeps the dataset selection query on the datasets view", () => {
+ renderApp("/datasets/detail?selection_key=dataset%3Aunnamed");
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "datasets"
+ );
+ expect(screen.getByTestId("dataset-selection")).toHaveAttribute(
+ "data-location",
+ "/datasets/detail?selection_key=dataset%3Aunnamed"
+ );
+ });
+
+ it("switches to the datasets view via the sidebar", () => {
+ renderApp();
+
+ fireEvent.click(screen.getByTestId("nav-datasets"));
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "datasets"
+ );
+ expect(screen.getByTestId("dataset-catalog")).toBeInTheDocument();
+ });
+
it("renders the scenario catalog when deep-linked to /scanner", () => {
renderApp("/scanner");
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index fed3129f4e..ccd71ceb99 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -22,6 +22,8 @@ import ScenarioHistory from './components/History/ScenarioHistory'
import ScenarioCatalog from './components/Scenarios/ScenarioCatalog'
import ScenarioDetail from './components/Scenarios/ScenarioDetail'
import ScenarioRunPage from './components/Scenarios/ScenarioRunPage'
+import DatasetCatalog from './components/Datasets/DatasetCatalog'
+import DatasetSelection from './components/Datasets/DatasetSelection'
import FeedbackDialog from './components/Feedback/FeedbackDialog'
import type { HistoryFilters } from './components/History/historyFilters'
import { ConnectionBanner } from './components/ConnectionBanner'
@@ -54,6 +56,8 @@ import { useTour } from './hooks/useTour'
import {
attackConversationRoutePath,
attackRoutePath,
+ DATASET_DETAIL_PATH,
+ DATASETS_PATH,
routerPathParamValue,
scenarioRunProvenance,
scenarioRunRoutePath,
@@ -70,6 +74,7 @@ const VIEW_PATHS: Record = {
history: HISTORY_ATTACKS_PATH,
registry: '/registry/targets',
scenarios: '/scanner',
+ datasets: DATASETS_PATH,
configuration: '/config',
}
@@ -92,6 +97,12 @@ function viewFromPath(pathname: string): ViewName {
) {
return 'scenarios'
}
+ if (
+ pathname === VIEW_PATHS.datasets
+ || pathname.startsWith(`${VIEW_PATHS.datasets}/`)
+ ) {
+ return 'datasets'
+ }
const match = (Object.entries(VIEW_PATHS) as [ViewName, string][]).find(
([, path]) => path === pathname,
)
@@ -735,6 +746,8 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
} />
} />
+ } />
+ } />
{NONE_LABEL}
+ }
+ return (
+
+ {values.map((value, index) => (
+ {value}
+ ))}
+ {unlabeled && (
+ {NOT_LABELED_LABEL}
+ )}
+
+ )
+}
+
+/** Summary card built only from one catalog record. */
+export default function DatasetCard({ dataset, linked }: DatasetCardProps) {
+ const styles = useDatasetCardStyles()
+ const location = useLocation()
+ const titleId = useId()
+ const descriptionId = useId()
+ const catalogPath = `${location.pathname}${location.search}`
+
+ return (
+
+ {linked && (
+
+
+ {dataset.name}
+
+
+ )}
+ {linked && (
+
+ Opens this dataset. Individual examples are not yet listed in this view. We are still building this experience.
+
+ )}
+
+
+
Logical examples
+ {formatDatasetCount(dataset.logical_examples)}
+
+
+
Seed pieces
+ {formatDatasetCount(dataset.seed_pieces)}
+
+
+
Objectives
+ {formatDatasetCount(dataset.objectives)}
+
+
+
+ Modalities
+
+
+
+ Harm categories
+
+
+
+ )
+}
diff --git a/frontend/src/components/Datasets/DatasetCatalog.styles.ts b/frontend/src/components/Datasets/DatasetCatalog.styles.ts
new file mode 100644
index 0000000000..8570982e8f
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetCatalog.styles.ts
@@ -0,0 +1,116 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import {
+ mobileTouchTarget,
+ mobileTouchTargetHeight,
+ NARROW_VIEWPORT_QUERY,
+ TOUCH_INPUT_QUERY,
+ MINIMUM_TOUCH_TARGET_SIZE,
+} from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useDatasetCatalogStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ header: {
+ display: 'flex',
+ alignItems: 'flex-start',
+ justifyContent: 'space-between',
+ flexWrap: 'wrap',
+ gap: tokens.spacingVerticalL,
+ marginBottom: tokens.spacingVerticalL,
+ [NARROW_VIEWPORT_QUERY]: {
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ },
+ },
+ headerText: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalXS,
+ minWidth: 0,
+ },
+ subtitle: {
+ maxWidth: '70ch',
+ color: tokens.colorNeutralForeground3,
+ },
+ filters: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ alignItems: 'center',
+ marginBottom: tokens.spacingVerticalL,
+ [NARROW_VIEWPORT_QUERY]: {
+ alignItems: 'stretch',
+ },
+ },
+ search: {
+ minWidth: '16rem',
+ [NARROW_VIEWPORT_QUERY]: {
+ minWidth: 0,
+ flex: '1 1 100%',
+ },
+ [TOUCH_INPUT_QUERY]: {
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ },
+ },
+ filterControl: {
+ minWidth: '12rem',
+ ...mobileTouchTargetHeight,
+ '& > input, & select': {
+ [TOUCH_INPUT_QUERY]: {
+ height: MINIMUM_TOUCH_TARGET_SIZE,
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ },
+ },
+ [NARROW_VIEWPORT_QUERY]: {
+ minWidth: 0,
+ flex: '1 1 12rem',
+ },
+ },
+ touchTarget: {
+ ...mobileTouchTarget,
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: tokens.spacingVerticalM,
+ padding: tokens.spacingVerticalXXXL,
+ textAlign: 'center',
+ color: tokens.colorNeutralForeground3,
+ },
+ guideLink: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ ...mobileTouchTargetHeight,
+ },
+ resultCount: {
+ margin: `0 0 ${tokens.spacingVerticalS}`,
+ color: tokens.colorNeutralForeground3,
+ },
+ grid: {
+ display: 'grid',
+ gridTemplateColumns: 'repeat(auto-fill, minmax(18rem, 1fr))',
+ gap: tokens.spacingHorizontalL,
+ margin: 0,
+ padding: 0,
+ listStyleType: 'none',
+ [NARROW_VIEWPORT_QUERY]: {
+ gridTemplateColumns: 'minmax(0, 1fr)',
+ },
+ },
+})
diff --git a/frontend/src/components/Datasets/DatasetCatalog.test.tsx b/frontend/src/components/Datasets/DatasetCatalog.test.tsx
new file mode 100644
index 0000000000..ef53e38411
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetCatalog.test.tsx
@@ -0,0 +1,489 @@
+import { useState } from 'react'
+import { act, render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router'
+
+import { apiClient } from '@/services/api'
+import type { DatasetInfo } from '@/types'
+
+import { DATASET_DETAIL_PATH, datasetDetailPath } from '@/utils/routeParams'
+
+import DatasetCatalog from './DatasetCatalog'
+import DatasetSelection from './DatasetSelection'
+
+let mockGet: jest.SpyInstance
+
+function mockCatalog(items: DatasetInfo[]): void {
+ mockGet.mockResolvedValue({ data: { items } })
+}
+
+function expectOnlyLoadedDatasetGets(): void {
+ expect(mockGet.mock.calls.length).toBeGreaterThan(0)
+ for (const call of mockGet.mock.calls) {
+ expect(call[0]).toBe('/datasets')
+ expect(call[1]).toEqual(expect.objectContaining({
+ params: { loaded_only: true },
+ }))
+ }
+}
+
+function makeDataset(
+ overrides: Partial & Pick,
+): DatasetInfo {
+ return {
+ loaded: true,
+ provider_available: false,
+ logical_examples: 2,
+ seed_pieces: 3,
+ objectives: 1,
+ modalities: ['text'],
+ harm_categories: ['hate'],
+ has_unlabeled_harm_categories: false,
+ ...overrides,
+ }
+}
+
+function Harness({ initialPath }: { initialPath: string }) {
+ return (
+
+
+
+
+
+ )
+}
+
+function RefreshableRoutes() {
+ const [epoch, setEpoch] = useState(0)
+ const location = useLocation()
+ const navigate = useNavigate()
+ return (
+ <>
+
+ } />
+ } />
+
+ {`${location.pathname}${location.search}${location.hash}`}
+ navigate(-1)}>History back
+ navigate(1)}>History forward
+ setEpoch((value) => value + 1)}>Refresh page
+ >
+ )
+}
+
+function currentUrl(): URL {
+ return new URL(screen.getByLabelText('Current URL').textContent ?? '', 'http://localhost')
+}
+
+function countValue(card: HTMLElement, label: string): string {
+ const term = within(card).getByText(label)
+ return term.parentElement?.querySelector('dd')?.textContent ?? ''
+}
+
+const LOADED = makeDataset({
+ name: 'harmbench',
+ selection_key: 'dataset:named:harmbench',
+ provider_available: true,
+ logical_examples: 1250000,
+ seed_pieces: 1400000,
+ objectives: 12,
+ modalities: ['text', 'image_path'],
+ harm_categories: ['hate'],
+ has_unlabeled_harm_categories: true,
+})
+
+const UNNAMED = makeDataset({
+ name: '(unnamed)',
+ selection_key: 'dataset:unnamed',
+ logical_examples: 4,
+ seed_pieces: 4,
+ objectives: 0,
+ modalities: [],
+ harm_categories: [],
+ has_unlabeled_harm_categories: true,
+})
+
+const NAMED_UNNAMED = makeDataset({
+ name: '__unnamed__',
+ selection_key: 'dataset:named:__unnamed__',
+ logical_examples: 1,
+ seed_pieces: 1,
+ objectives: null,
+ modalities: ['audio_path'],
+ harm_categories: [],
+ has_unlabeled_harm_categories: false,
+})
+
+describe('DatasetCatalog', () => {
+ beforeEach(() => {
+ mockGet = jest.spyOn(apiClient, 'get')
+ mockCatalog([LOADED, UNNAMED, NAMED_UNNAMED])
+ })
+
+ afterEach(() => {
+ expectOnlyLoadedDatasetGets()
+ jest.restoreAllMocks()
+ })
+
+ it('shows a loading state while the catalog request is in flight', () => {
+ mockGet.mockReturnValue(new Promise(() => {}))
+ render( )
+ expect(screen.getByText('Loading datasets...')).toBeInTheDocument()
+ })
+
+ it('renders summary cards from the catalog response', async () => {
+ render( )
+
+ const loaded = await screen.findByRole('article', { name: 'harmbench' })
+ expect(screen.getByRole('heading', { name: 'Datasets' })).toBeInTheDocument()
+ expect(within(loaded).getByRole('heading', { level: 2, name: 'harmbench' })).toBeInTheDocument()
+ expect(within(loaded).getByRole('link', { name: 'harmbench' })).toHaveAttribute(
+ 'href',
+ datasetDetailPath('dataset:named:harmbench'),
+ )
+ expect(within(loaded).getByRole('link', { name: 'harmbench' })).toHaveAccessibleDescription(
+ 'Opens this dataset. Individual examples are not yet listed in this view. We are still building this experience.',
+ )
+ expect(within(loaded).queryByText('Loaded')).not.toBeInTheDocument()
+ expect(within(loaded).queryByText('Not loaded')).not.toBeInTheDocument()
+ expect(within(loaded).queryByText('Provider available')).not.toBeInTheDocument()
+ expect(countValue(loaded, 'Logical examples')).toBe('1,250,000')
+ expect(countValue(loaded, 'Seed pieces')).toBe('1,400,000')
+ expect(countValue(loaded, 'Objectives')).toBe('12')
+ expect(within(loaded).getByText('text')).toBeInTheDocument()
+ expect(within(loaded).getByText('image_path')).toBeInTheDocument()
+ expect(within(loaded).getByText('hate')).toBeInTheDocument()
+ expect(within(loaded).getByText('Not labeled')).toBeInTheDocument()
+ expect(within(loaded).queryByText('Safe')).not.toBeInTheDocument()
+ expect(screen.queryByRole('table')).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /download|load dataset/i })).not.toBeInTheDocument()
+
+ const unnamed = screen.getByRole('article', { name: '(unnamed)' })
+ expect(within(unnamed).getByRole('link', { name: '(unnamed)' })).toHaveAttribute(
+ 'href',
+ datasetDetailPath('dataset:unnamed'),
+ )
+ expect(within(unnamed).queryByRole('link', { name: '__unnamed__' })).not.toBeInTheDocument()
+ expect(countValue(unnamed, 'Objectives')).toBe('0')
+ expect(within(unnamed).getByText('None')).toBeInTheDocument()
+ expect(within(unnamed).getByText('Not labeled')).toBeInTheDocument()
+
+ const literal = screen.getByRole('article', { name: '__unnamed__' })
+ expect(within(literal).getByRole('link', { name: '__unnamed__' })).toHaveAttribute(
+ 'href',
+ datasetDetailPath('dataset:named:__unnamed__'),
+ )
+ expect(countValue(literal, 'Objectives')).toBe('Unknown')
+ expect(within(literal).getByText('None')).toBeInTheDocument()
+ })
+
+ it('shows an empty state when memory has no datasets', async () => {
+ mockCatalog([])
+ render( )
+
+ expect(await screen.findByText('No datasets in memory')).toBeInTheDocument()
+ expect(screen.getByText(/does not download them/i)).toBeInTheDocument()
+ const guide = screen.getByRole('link', { name: 'Learn how to load datasets into memory' })
+ expect(guide).toHaveAttribute('href', 'https://microsoft.github.io/PyRIT/latest/code/datasets/loading-datasets/')
+ expect(guide).toHaveAttribute('target', '_blank')
+ expect(guide).toHaveAttribute('rel', 'noopener noreferrer')
+ expect(screen.queryByRole('link', { name: /harmbench|gated/i })).not.toBeInTheDocument()
+ expect(screen.queryByRole('button', { name: /load|download/i })).not.toBeInTheDocument()
+ })
+
+ it('shows an error and retries the catalog request', async () => {
+ const user = userEvent.setup()
+ mockGet.mockRejectedValueOnce(new Error('Catalog unavailable'))
+ render( )
+
+ expect(await screen.findByText('Catalog unavailable')).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Retry' })).toHaveFocus())
+
+ mockGet.mockResolvedValueOnce({ data: { items: [LOADED] } })
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+
+ expect(await screen.findByRole('link', { name: 'harmbench' })).toBeInTheDocument()
+ expect(mockGet).toHaveBeenCalledTimes(2)
+ })
+
+ it('keeps one live status through results, no matches, and clearing the search', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ const catalog = await screen.findByTestId('dataset-catalog')
+ const status = await within(catalog).findByRole('status')
+ expect(status).toHaveTextContent('3 datasets')
+
+ await user.type(screen.getByRole('textbox', { name: 'Search datasets' }), 'does-not-exist')
+
+ expect(status).toHaveTextContent('No datasets match "does-not-exist"')
+ expect(within(catalog).getByRole('status')).toBe(status)
+
+ await user.clear(screen.getByRole('textbox', { name: 'Search datasets' }))
+
+ expect(status).toHaveTextContent('3 datasets')
+ expect(within(catalog).getByRole('status')).toBe(status)
+ })
+
+ it('filters by name and reports no matches without rendering the query as HTML', async () => {
+ const user = userEvent.setup()
+ render( )
+ const search = await screen.findByRole('textbox', { name: 'Search datasets' })
+
+ await user.type(search, ' ')
+
+ expect(await screen.findByText(/No datasets match " "/)).toBeInTheDocument()
+ expect(document.querySelector('img')).toBeNull()
+ expect(currentUrl().searchParams.get('q')).toBe(' ')
+
+ await user.clear(search)
+ await user.type(search, 'zzz')
+ expect(await screen.findByText('No datasets match "zzz"')).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }))
+ expect(await screen.findByRole('link', { name: 'harmbench' })).toBeInTheDocument()
+ expect(currentUrl().search).toBe('')
+ })
+
+ it('keeps catalog filters when the back link returns from a card', async () => {
+ const user = userEvent.setup()
+ mockCatalog([
+ makeDataset({
+ name: 'policy bench',
+ selection_key: 'dataset:named:policy',
+ modalities: ['text'],
+ }),
+ ])
+ render( )
+
+ await user.click(await screen.findByRole('link', { name: 'policy bench' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'policy bench' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute(
+ 'href',
+ '/datasets?q=policy&modality=text&sort=logical_examples_desc',
+ )
+
+ await user.click(screen.getByRole('link', { name: 'Back to datasets' }))
+
+ expect(await screen.findByRole('link', { name: 'policy bench' })).toBeInTheDocument()
+ expect(currentUrl().pathname).toBe('/datasets')
+ expect(currentUrl().searchParams.get('q')).toBe('policy')
+ expect(currentUrl().searchParams.get('modality')).toBe('text')
+ expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc')
+ })
+
+ it('shows the no-match copy when only facets exclude every dataset', async () => {
+ render( )
+ expect(await screen.findByText('No datasets match these filters')).toBeInTheDocument()
+ })
+
+ it('updates the URL from the sort, modality, and harm controls', async () => {
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('link', { name: 'harmbench' })
+
+ await user.selectOptions(screen.getByRole('combobox', { name: 'Sort datasets' }), 'logical_examples_desc')
+ expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc')
+
+ await user.click(screen.getByTestId('dataset-modality-filter'))
+ await user.click(await screen.findByRole('menuitemcheckbox', { name: 'audio_path' }))
+ expect(currentUrl().searchParams.get('modality')).toBe('audio_path')
+ expect(screen.getByRole('link', { name: '__unnamed__' })).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }))
+ await user.click(screen.getByTestId('dataset-harm-filter'))
+ await user.click(await screen.findByRole('menuitemcheckbox', { name: 'hate' }))
+ expect(currentUrl().searchParams.get('harm_category')).toBe('hate')
+ expect(screen.getByRole('link', { name: 'harmbench' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('checkbox', { name: 'Not labeled' }))
+ expect(currentUrl().searchParams.get('unlabeled')).toBe('true')
+ expect(screen.getByRole('link', { name: '(unnamed)' })).toBeInTheDocument()
+ })
+
+ it('reaches search, clear, and the dataset link from the keyboard', async () => {
+ const user = userEvent.setup()
+ render( )
+ const search = await screen.findByRole('textbox', { name: 'Search datasets' })
+
+ for (let step = 0; step < 8 && document.activeElement !== search; step += 1) {
+ await user.tab()
+ }
+ expect(search).toHaveFocus()
+ await user.keyboard('zzz')
+
+ const clear = await screen.findByRole('button', { name: 'Clear filters' })
+ for (let step = 0; step < 12 && document.activeElement !== clear; step += 1) {
+ await user.tab()
+ }
+ expect(clear).toHaveFocus()
+ await user.keyboard('{Enter}')
+
+ const link = await screen.findByRole('link', { name: 'harmbench' })
+ for (let step = 0; step < 15 && document.activeElement !== link; step += 1) {
+ await user.tab()
+ }
+ expect(link).toHaveFocus()
+ await user.keyboard('{Enter}')
+
+ expect(await screen.findByRole('heading', { level: 1, name: 'harmbench' })).toBeInTheDocument()
+ expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH)
+ expect(currentUrl().searchParams.get('selection_key')).toBe('dataset:named:harmbench')
+ expect(screen.queryByRole('table')).not.toBeInTheDocument()
+ })
+
+ it('ignores a catalog response that arrives after the page is left', async () => {
+ let resolveLoad: (value: { data: { items: DatasetInfo[] } }) => void = () => {}
+ mockGet.mockReturnValue(new Promise((resolve) => {
+ resolveLoad = resolve
+ }))
+ const { unmount } = render( )
+ unmount()
+ await act(async () => {
+ resolveLoad({ data: { items: [LOADED] } })
+ })
+ expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument()
+ })
+})
+
+function catalogUrl(): string {
+ const params = new URLSearchParams()
+ params.set('q', 'データ')
+ params.append('modality', 'image/png')
+ params.append('harm_category', 'a&b')
+ params.set('unlabeled', 'true')
+ params.set('sort', 'logical_examples_desc')
+ return `/datasets?${params.toString()}`
+}
+
+describe('DatasetCatalog URL state', () => {
+ beforeEach(() => {
+ mockGet = jest.spyOn(apiClient, 'get')
+ mockCatalog([
+ makeDataset({
+ name: 'データ set',
+ selection_key: 'dataset:named:データ set',
+ modalities: ['image/png'],
+ harm_categories: ['a&b'],
+ has_unlabeled_harm_categories: true,
+ objectives: 9,
+ }),
+ makeDataset({
+ name: 'other',
+ selection_key: 'dataset:named:other',
+ modalities: ['text'],
+ harm_categories: ['violence'],
+ objectives: 1,
+ }),
+ ])
+ })
+
+ afterEach(() => {
+ expectOnlyLoadedDatasetGets()
+ jest.restoreAllMocks()
+ })
+
+ it('keeps search, facets, and sort in the URL across refresh, back, and forward', async () => {
+ const user = userEvent.setup()
+ render( )
+
+ expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'other' })).not.toBeInTheDocument()
+ expect(screen.getByRole('textbox', { name: 'Search datasets' })).toHaveValue('データ')
+ expect(screen.getByRole('combobox', { name: 'Sort datasets' })).toHaveValue('logical_examples_desc')
+ expect(screen.getByRole('checkbox', { name: 'Not labeled' })).toBeChecked()
+ expect(screen.getByTestId('dataset-modality-filter')).toHaveValue('Modalities: image/png')
+ expect(screen.getByTestId('dataset-harm-filter')).toHaveValue('Harm categories: a&b')
+
+ await user.click(screen.getByRole('button', { name: 'Refresh page' }))
+ expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument()
+ expect(currentUrl().searchParams.get('q')).toBe('データ')
+ expect(currentUrl().searchParams.getAll('modality')).toEqual(['image/png'])
+ expect(currentUrl().searchParams.getAll('harm_category')).toEqual(['a&b'])
+ expect(currentUrl().searchParams.get('unlabeled')).toBe('true')
+ expect(currentUrl().searchParams.get('sort')).toBe('logical_examples_desc')
+
+ await user.click(screen.getByRole('link', { name: 'データ set' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'データ set' })).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'History back' }))
+
+ expect(await screen.findByRole('link', { name: 'データ set' })).toBeInTheDocument()
+ expect(currentUrl().pathname).toBe('/datasets')
+ expect(currentUrl().searchParams.get('q')).toBe('データ')
+ expect(currentUrl().searchParams.get('harm_category')).toBe('a&b')
+ expect(screen.getByRole('textbox', { name: 'Search datasets' })).toHaveValue('データ')
+
+ await user.click(screen.getByRole('button', { name: 'History forward' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'データ set' })).toBeInTheDocument()
+ expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH)
+ expect(currentUrl().searchParams.get('selection_key')).toBe('dataset:named:データ set')
+ })
+})
+
+const SELECTION_CASES: Array<[string, string, string]> = [
+ ['unnamed population', 'dataset:unnamed', '(unnamed)'],
+ ['literal __unnamed__ name', 'dataset:named:__unnamed__', '__unnamed__'],
+ ['spaces', 'dataset:named:my dataset', 'my dataset'],
+ ['slash', 'dataset:named:a/b', 'a/b'],
+ ['question mark', 'dataset:named:a?b', 'a?b'],
+ ['hash', 'dataset:named:a#b', 'a#b'],
+ ['ampersand', 'dataset:named:a&b', 'a&b'],
+ ['percent', 'dataset:named:100%', '100%'],
+ ['unicode', 'dataset:named:データ', 'データ'],
+]
+
+describe('Dataset selection keys', () => {
+ beforeEach(() => {
+ mockGet = jest.spyOn(apiClient, 'get')
+ })
+
+ afterEach(() => {
+ expectOnlyLoadedDatasetGets()
+ jest.restoreAllMocks()
+ })
+
+ it.each(SELECTION_CASES)(
+ 'round-trips the %s key through navigation, refresh, and back',
+ async (_label, selectionKey, displayName) => {
+ const user = userEvent.setup()
+ mockCatalog([makeDataset({
+ name: displayName,
+ selection_key: selectionKey,
+ modalities: [],
+ harm_categories: [],
+ })])
+
+ render( )
+ const link = await screen.findByRole('link', { name: displayName })
+ const href = link.getAttribute('href') ?? ''
+ const hrefUrl = new URL(href, 'http://localhost')
+ expect(hrefUrl.pathname).toBe(DATASET_DETAIL_PATH)
+ expect(href.split('?')[0]).toBe(DATASET_DETAIL_PATH)
+ expect(hrefUrl.hash).toBe('')
+ expect(Array.from(hrefUrl.searchParams.keys())).toEqual(['selection_key'])
+ expect(hrefUrl.searchParams.get('selection_key')).toBe(selectionKey)
+
+ await user.click(link)
+ expect(await screen.findByRole('heading', { level: 1, name: displayName })).toBeInTheDocument()
+ expect(currentUrl().pathname).toBe(DATASET_DETAIL_PATH)
+ expect(currentUrl().hash).toBe('')
+ expect(currentUrl().searchParams.get('selection_key')).toBe(selectionKey)
+ expect(screen.queryByRole('table')).not.toBeInTheDocument()
+ expect(screen.getByText(/Individual examples are not yet listed in this view\.\s+We are still building this experience\./)).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Refresh page' }))
+ expect(await screen.findByRole('heading', { level: 1, name: displayName })).toBeInTheDocument()
+ expect(currentUrl().searchParams.get('selection_key')).toBe(selectionKey)
+ expect(currentUrl().hash).toBe('')
+
+ await user.click(screen.getByRole('button', { name: 'History back' }))
+ const returned = await screen.findByRole('link', { name: displayName })
+ expect(currentUrl().pathname).toBe('/datasets')
+ expect(new URL(returned.getAttribute('href') ?? '', 'http://localhost').searchParams.get('selection_key'))
+ .toBe(selectionKey)
+ },
+ )
+})
diff --git a/frontend/src/components/Datasets/DatasetCatalog.tsx b/frontend/src/components/Datasets/DatasetCatalog.tsx
new file mode 100644
index 0000000000..5c2d7bee3d
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetCatalog.tsx
@@ -0,0 +1,221 @@
+import { useEffect, useId, useMemo, useRef } from 'react'
+
+import {
+ Button,
+ Checkbox,
+ Input,
+ Link,
+ Select,
+ Spinner,
+ Text,
+ MessageBar,
+ MessageBarBody,
+} from '@fluentui/react-components'
+import { ArrowSyncRegular, SearchRegular } from '@fluentui/react-icons'
+import { useSearchParams } from 'react-router'
+
+import SearchableMultiCombobox from '@/components/SearchableMultiCombobox'
+import { useLoadedDatasets } from '@/hooks/useLoadedDatasets'
+import type { DatasetInfo, FilterOption } from '@/types'
+
+import DatasetCard from './DatasetCard'
+import { useDatasetCatalogStyles } from './DatasetCatalog.styles'
+import {
+ catalogQueryFromSearchParams,
+ catalogQueryIsDefault,
+ catalogQueryToSearchParams,
+ DATASET_SORT_LABELS,
+ DATASET_SORTS,
+ DEFAULT_DATASET_CATALOG_QUERY,
+ filterDatasets,
+ isDatasetSort,
+ type DatasetCatalogQuery,
+} from './datasetQuery'
+
+function uniqueLabels(values: readonly string[]): FilterOption[] {
+ const seen = new Set()
+ const options: FilterOption[] = []
+ for (const value of values) {
+ if (seen.has(value)) continue
+ seen.add(value)
+ options.push({ value, label: value })
+ }
+ options.sort((left, right) => left.label.localeCompare(right.label, 'en', { sensitivity: 'base' }))
+ return options
+}
+
+function modalityOptions(datasets: readonly DatasetInfo[]): FilterOption[] {
+ const values: string[] = []
+ for (const dataset of datasets) values.push(...dataset.modalities)
+ return uniqueLabels(values)
+}
+
+function harmOptions(datasets: readonly DatasetInfo[]): FilterOption[] {
+ const values: string[] = []
+ for (const dataset of datasets) values.push(...dataset.harm_categories)
+ return uniqueLabels(values)
+}
+
+/** Loaded-dataset catalog. Search, facets, and sort stay in the URL. */
+export default function DatasetCatalog() {
+ const styles = useDatasetCatalogStyles()
+ const titleId = useId()
+ const retryRef = useRef(null)
+ const [searchParams, setSearchParams] = useSearchParams()
+ const catalogSearch = searchParams.toString()
+ const query = useMemo(
+ () => catalogQueryFromSearchParams(new URLSearchParams(catalogSearch)),
+ [catalogSearch],
+ )
+ const { datasets, loading, error, retry } = useLoadedDatasets()
+
+ useEffect(() => {
+ if (error) retryRef.current?.focus()
+ }, [error])
+
+ const filtered = useMemo(() => filterDatasets(datasets, query), [datasets, query])
+ const modalities = useMemo(() => modalityOptions(datasets), [datasets])
+ const harms = useMemo(() => harmOptions(datasets), [datasets])
+ const filtersActive = !catalogQueryIsDefault(query)
+
+ const updateQuery = (next: DatasetCatalogQuery): void => {
+ setSearchParams(catalogQueryToSearchParams(next), { replace: true })
+ }
+
+ const resultLabel = filtered.length === 1 ? '1 dataset' : `${filtered.length} datasets`
+ const statusLabel = filtered.length === 0
+ ? (query.search.trim()
+ ? `No datasets match "${query.search.trim()}"`
+ : 'No datasets match these filters')
+ : resultLabel
+
+ return (
+
+
+
+ Datasets
+
+ Loaded datasets in memory. Search, filters, and sort stay in the address bar.
+ This view does not download datasets or list individual examples.
+
+
+
}
+ onClick={retry}
+ disabled={loading}
+ >
+ Refresh
+
+
+
+
+ }
+ placeholder="Search datasets by name"
+ value={query.search}
+ onChange={(_, data) => updateQuery({ ...query, search: data.value })}
+ aria-label="Search datasets"
+ />
+ updateQuery({ ...query, modalities: selected })}
+ testId="dataset-modality-filter"
+ />
+ updateQuery({ ...query, harmCategories: selected })}
+ testId="dataset-harm-filter"
+ />
+ updateQuery({ ...query, unlabeled: data.checked === true })}
+ label="Not labeled"
+ />
+ updateQuery({
+ ...query,
+ sort: isDatasetSort(data.value) ? data.value : DEFAULT_DATASET_CATALOG_QUERY.sort,
+ })}
+ >
+ {DATASET_SORTS.map((sort) => (
+ {DATASET_SORT_LABELS[sort]}
+ ))}
+
+ {filtersActive && (
+ updateQuery(DEFAULT_DATASET_CATALOG_QUERY)}>
+ Clear filters
+
+ )}
+
+
+ {loading ? (
+
+
+
+ ) : error ? (
+
+
+ {error}
+
+ }
+ onClick={retry}
+ >
+ Retry
+
+
+ ) : datasets.length === 0 ? (
+
+ No datasets in memory
+
+ Datasets show up here after they are loaded into memory. This view does not download them.
+
+
+ Learn how to load datasets into memory
+
+
+ ) : (
+ <>
+ {statusLabel}
+ {filtered.length === 0 ? (
+
+ Clear the search or filters to see every loaded dataset.
+
+ ) : (
+
+ {filtered.map((dataset) => (
+
+
+
+ ))}
+
+ )}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Datasets/DatasetSelection.styles.ts b/frontend/src/components/Datasets/DatasetSelection.styles.ts
new file mode 100644
index 0000000000..3292e8d0d0
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetSelection.styles.ts
@@ -0,0 +1,73 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+import { MINIMUM_TOUCH_TARGET_SIZE, NARROW_VIEWPORT_QUERY, TOUCH_INPUT_QUERY } from '@/styles/touchTargets'
+import { WORKSPACE_CANVAS_BACKGROUND } from '@/styles/workspaceBackground'
+
+export const useDatasetSelectionStyles = makeStyles({
+ root: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: tokens.spacingVerticalL,
+ height: '100%',
+ width: '100%',
+ minWidth: 0,
+ padding: tokens.spacingVerticalXXL,
+ overflowX: 'hidden',
+ overflowY: 'auto',
+ backgroundColor: WORKSPACE_CANVAS_BACKGROUND,
+ [NARROW_VIEWPORT_QUERY]: {
+ padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`,
+ },
+ },
+ title: {
+ margin: 0,
+ color: tokens.colorNeutralForeground1,
+ fontSize: tokens.fontSizeBase600,
+ lineHeight: tokens.lineHeightBase600,
+ fontWeight: tokens.fontWeightSemibold,
+ overflowWrap: 'anywhere',
+ ':focus': {
+ outline: `2px solid ${tokens.colorStrokeFocus2}`,
+ outlineOffset: '2px',
+ },
+ },
+ backLink: {
+ color: tokens.colorBrandForegroundLink,
+ fontWeight: tokens.fontWeightSemibold,
+ textDecorationLine: 'none',
+ ':hover': {
+ textDecorationLine: 'underline',
+ },
+ ':focus-visible': {
+ outline: `2px solid ${tokens.colorStrokeFocus2}`,
+ outlineOffset: '2px',
+ },
+ [TOUCH_INPUT_QUERY]: {
+ display: 'inline-flex',
+ alignItems: 'center',
+ minHeight: MINIMUM_TOUCH_TARGET_SIZE,
+ },
+ },
+ summary: {
+ width: '100%',
+ maxWidth: '40rem',
+ },
+ note: {
+ maxWidth: '65ch',
+ margin: 0,
+ color: tokens.colorNeutralForeground2,
+ },
+ selectionKey: {
+ margin: 0,
+ maxWidth: '100%',
+ overflowWrap: 'anywhere',
+ color: tokens.colorNeutralForeground2,
+ },
+ centeredState: {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'flex-start',
+ gap: tokens.spacingVerticalM,
+ },
+})
diff --git a/frontend/src/components/Datasets/DatasetSelection.test.tsx b/frontend/src/components/Datasets/DatasetSelection.test.tsx
new file mode 100644
index 0000000000..ff88daae6a
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetSelection.test.tsx
@@ -0,0 +1,150 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { FluentProvider, webLightTheme } from '@fluentui/react-components'
+import { MemoryRouter, Route, Routes } from 'react-router'
+
+import { apiClient } from '@/services/api'
+import type { DatasetInfo } from '@/types'
+
+import { datasetDetailPath } from '@/utils/routeParams'
+
+import DatasetSelection from './DatasetSelection'
+
+let mockGet: jest.SpyInstance
+
+function expectOnlyLoadedDatasetGets(): void {
+ expect(mockGet.mock.calls.length).toBeGreaterThan(0)
+ for (const call of mockGet.mock.calls) {
+ expect(call[0]).toBe('/datasets')
+ expect(call[1]).toEqual(expect.objectContaining({
+ params: { loaded_only: true },
+ }))
+ }
+}
+
+function makeDataset(
+ overrides: Partial & Pick,
+): DatasetInfo {
+ return {
+ loaded: true,
+ provider_available: true,
+ logical_examples: 2,
+ seed_pieces: 2,
+ objectives: 2,
+ modalities: ['text'],
+ harm_categories: ['hate'],
+ has_unlabeled_harm_categories: false,
+ ...overrides,
+ }
+}
+
+function renderSelection(initialPath: string, state?: unknown) {
+ const url = new URL(initialPath, 'http://localhost')
+ return render(
+
+
+
+ Catalog } />
+ } />
+
+
+ ,
+ )
+}
+
+const LOADED = makeDataset({
+ name: 'harmbench',
+ selection_key: 'dataset:named:harmbench',
+})
+
+describe('DatasetSelection', () => {
+ beforeEach(() => {
+ mockGet = jest.spyOn(apiClient, 'get').mockResolvedValue({ data: { items: [LOADED] } })
+ })
+
+ afterEach(() => {
+ expectOnlyLoadedDatasetGets()
+ jest.restoreAllMocks()
+ })
+
+ it('focuses the dataset name when the selection key matches the catalog', async () => {
+ renderSelection(datasetDetailPath(LOADED.selection_key))
+
+ const heading = await screen.findByRole('heading', { level: 1, name: 'harmbench' })
+ await waitFor(() => expect(heading).toHaveFocus())
+ expect(screen.getByText(/Individual examples are not yet listed in this view\.\s+We are still building this experience\./)).toBeInTheDocument()
+ expect(screen.queryByRole('table')).not.toBeInTheDocument()
+ expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument()
+ const summary = screen.getByRole('article', { name: 'harmbench' })
+ expect(within(summary).queryByRole('heading')).not.toBeInTheDocument()
+ })
+
+ it('shows not-found for an unknown key and renders that key as text', async () => {
+ const key = ' '
+ renderSelection(datasetDetailPath(key))
+
+ const heading = await screen.findByRole('heading', { name: 'Dataset not found' })
+ await waitFor(() => expect(heading).toHaveFocus())
+ expect(screen.getByText(key)).toBeInTheDocument()
+ expect(document.querySelector('img')).toBeNull()
+ expect(document.querySelector('script')).toBeNull()
+ expect(screen.queryByRole('link', { name: 'harmbench' })).not.toBeInTheDocument()
+ })
+
+ it('truncates a long unknown selection key and renders it as text', async () => {
+ const key = ` ${'a'.repeat(200)}`
+ renderSelection(datasetDetailPath(key))
+
+ const shown = `${key.slice(0, 79)}…`
+ expect(await screen.findByText(shown)).toBeInTheDocument()
+ expect(screen.queryByText(key)).not.toBeInTheDocument()
+ expect(document.querySelector('img')).toBeNull()
+ expect(document.querySelector('script')).toBeNull()
+ })
+
+ it('does not choose between repeated keys or fetch them', async () => {
+ renderSelection('/datasets/detail?selection_key=dataset:named:harmbench&selection_key=dataset:named:other')
+
+ expect(await screen.findByRole('heading', { name: 'Dataset not found' })).toBeInTheDocument()
+ expect(screen.queryByRole('heading', { name: 'harmbench' })).not.toBeInTheDocument()
+ expect(screen.queryByText('dataset:named:harmbench')).not.toBeInTheDocument()
+ })
+
+ it('shows not-found when the selection key is missing', async () => {
+ renderSelection('/datasets/detail')
+
+ expect(await screen.findByRole('heading', { name: 'Dataset not found' })).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets')
+ })
+
+ it('focuses retry after a catalog error and can load again', async () => {
+ const user = userEvent.setup()
+ mockGet.mockRejectedValueOnce(new Error('Catalog unavailable'))
+ renderSelection(datasetDetailPath(LOADED.selection_key))
+
+ expect(await screen.findByText('Catalog unavailable')).toBeInTheDocument()
+ await waitFor(() => expect(screen.getByRole('button', { name: 'Retry' })).toHaveFocus())
+
+ mockGet.mockResolvedValueOnce({ data: { items: [LOADED] } })
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(await screen.findByRole('heading', { level: 1, name: 'harmbench' })).toBeInTheDocument()
+ })
+
+ it('returns to the catalog from the back link', async () => {
+ const user = userEvent.setup()
+ renderSelection(datasetDetailPath(LOADED.selection_key))
+ await screen.findByRole('heading', { level: 1, name: 'harmbench' })
+
+ expect(screen.getByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets')
+ await user.click(screen.getByRole('link', { name: 'Back to datasets' }))
+ expect(await screen.findByText('Catalog')).toBeInTheDocument()
+ })
+
+ it('ignores a return path that is not the datasets catalog', async () => {
+ renderSelection(datasetDetailPath(LOADED.selection_key), {
+ catalogPath: 'https://evil.example/datasets?q=policy',
+ })
+
+ expect(await screen.findByRole('link', { name: 'Back to datasets' })).toHaveAttribute('href', '/datasets')
+ })
+})
diff --git a/frontend/src/components/Datasets/DatasetSelection.tsx b/frontend/src/components/Datasets/DatasetSelection.tsx
new file mode 100644
index 0000000000..69d868423a
--- /dev/null
+++ b/frontend/src/components/Datasets/DatasetSelection.tsx
@@ -0,0 +1,89 @@
+import { useEffect, useRef } from 'react'
+
+import { Button, MessageBar, MessageBarBody, Spinner, Text } from '@fluentui/react-components'
+import { ArrowSyncRegular } from '@fluentui/react-icons'
+import { Link, useLocation, useSearchParams } from 'react-router'
+
+import { useLoadedDatasets } from '@/hooks/useLoadedDatasets'
+import { catalogPathFromNavigationState, selectionKeyFromSearchParams } from '@/utils/routeParams'
+
+import DatasetCard from './DatasetCard'
+import { useDatasetSelectionStyles } from './DatasetSelection.styles'
+
+const MAX_SHOWN_SELECTION_KEY_LENGTH = 80
+
+function shownSelectionKey(selectionKey: string): string {
+ if (selectionKey.length <= MAX_SHOWN_SELECTION_KEY_LENGTH) return selectionKey
+ return `${selectionKey.slice(0, MAX_SHOWN_SELECTION_KEY_LENGTH - 1)}…`
+}
+
+/**
+ * Navigation contract for a later prompt table. The selection key is compared
+ * with the catalog response and is not sent to a seed endpoint.
+ */
+export default function DatasetSelection() {
+ const styles = useDatasetSelectionStyles()
+ const headingRef = useRef(null)
+ const retryRef = useRef(null)
+ const location = useLocation()
+ const [searchParams] = useSearchParams()
+ const selectionKey = selectionKeyFromSearchParams(searchParams)
+ const catalogPath = catalogPathFromNavigationState(location.state)
+ const { datasets, loading, error, retry } = useLoadedDatasets()
+ const dataset = selectionKey == null
+ ? undefined
+ : datasets.find((item) => item.selection_key === selectionKey)
+
+ useEffect(() => {
+ if (loading) return
+ if (error) {
+ retryRef.current?.focus()
+ return
+ }
+ headingRef.current?.focus()
+ }, [loading, error, selectionKey, dataset?.selection_key])
+
+ const title = loading
+ ? 'Loading dataset'
+ : error
+ ? 'Could not load datasets'
+ : dataset
+ ? dataset.name
+ : 'Dataset not found'
+
+ return (
+
+ Back to datasets
+
+ {title}
+
+ {loading ? (
+
+ ) : error ? (
+
+
+ {error}
+
+ } onClick={retry}>
+ Retry
+
+
+ ) : dataset ? (
+ <>
+
+
+
+
+ Individual examples are not yet listed in this view.
+ We are still building this experience.
+
+ >
+ ) : (
+ <>
+ No loaded dataset matches this selection.
+ {selectionKey ? {shownSelectionKey(selectionKey)}
: null}
+ >
+ )}
+
+ )
+}
diff --git a/frontend/src/components/Datasets/datasetQuery.test.ts b/frontend/src/components/Datasets/datasetQuery.test.ts
new file mode 100644
index 0000000000..5bb1578a9a
--- /dev/null
+++ b/frontend/src/components/Datasets/datasetQuery.test.ts
@@ -0,0 +1,142 @@
+import type { DatasetInfo } from '@/types'
+
+import {
+ catalogQueryFromSearchParams,
+ catalogQueryIsDefault,
+ catalogQueryToSearchParams,
+ DEFAULT_DATASET_CATALOG_QUERY,
+ filterDatasets,
+ type DatasetCatalogQuery,
+ type DatasetSort,
+} from './datasetQuery'
+
+function makeDataset(overrides: Partial & Pick): DatasetInfo {
+ return {
+ loaded: true,
+ provider_available: false,
+ logical_examples: 1,
+ seed_pieces: 1,
+ objectives: 1,
+ modalities: [],
+ harm_categories: [],
+ has_unlabeled_harm_categories: false,
+ ...overrides,
+ }
+}
+
+describe('datasetQuery', () => {
+ it('omits default catalog filters and restores special characters', () => {
+ expect(catalogQueryToSearchParams(DEFAULT_DATASET_CATALOG_QUERY).toString()).toBe('')
+ expect(catalogQueryIsDefault(DEFAULT_DATASET_CATALOG_QUERY)).toBe(true)
+
+ const query: DatasetCatalogQuery = {
+ search: 'a/b?c#d&e% fü',
+ modalities: ['image/png', 'text'],
+ harmCategories: ['a&b', 'データ'],
+ unlabeled: true,
+ sort: 'logical_examples_desc',
+ }
+ const params = catalogQueryToSearchParams(query)
+ const restored = catalogQueryFromSearchParams(params)
+
+ expect(restored).toEqual(query)
+ expect(catalogQueryIsDefault(restored)).toBe(false)
+ expect(params.getAll('modality')).toEqual(['image/png', 'text'])
+ expect(params.getAll('harm_category')).toEqual(['a&b', 'データ'])
+ })
+
+ it('ignores an unknown sort value', () => {
+ expect(catalogQueryFromSearchParams(new URLSearchParams('sort=nope')).sort).toBe('name')
+ })
+
+ it('filters by name, modality, and harm without treating unlabeled rows as safe', () => {
+ const hate = makeDataset({
+ name: 'Hate bench',
+ selection_key: 'dataset:named:hate',
+ modalities: ['text'],
+ harm_categories: ['hate'],
+ })
+ const unlabeled = makeDataset({
+ name: 'Plain',
+ selection_key: 'dataset:named:plain',
+ modalities: ['image_path'],
+ has_unlabeled_harm_categories: true,
+ })
+ const datasets = [hate, unlabeled]
+
+ expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, search: ' HATE ' }).map((item) => item.name))
+ .toEqual(['Hate bench'])
+ expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, modalities: ['image_path'] }).map((item) => item.name))
+ .toEqual(['Plain'])
+ expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, harmCategories: ['hate'] }).map((item) => item.name))
+ .toEqual(['Hate bench'])
+ expect(filterDatasets(datasets, { ...DEFAULT_DATASET_CATALOG_QUERY, unlabeled: true }).map((item) => item.name))
+ .toEqual(['Plain'])
+ expect(filterDatasets(datasets, {
+ ...DEFAULT_DATASET_CATALOG_QUERY,
+ harmCategories: ['hate'],
+ unlabeled: true,
+ }).map((item) => item.name)).toEqual(['Hate bench', 'Plain'])
+ expect(filterDatasets(datasets, {
+ ...DEFAULT_DATASET_CATALOG_QUERY,
+ modalities: ['text'],
+ unlabeled: true,
+ })).toEqual([])
+ })
+
+ it('sorts names and known counts with deterministic ties', () => {
+ const alpha = makeDataset({
+ name: 'alpha',
+ selection_key: 'dataset:named:alpha',
+ logical_examples: 2,
+ })
+ const beta = makeDataset({
+ name: 'beta',
+ selection_key: 'dataset:named:beta',
+ logical_examples: 10,
+ })
+ const same = makeDataset({
+ name: 'alpha',
+ selection_key: 'dataset:named:alpha-2',
+ logical_examples: 2,
+ })
+ const rows = [beta, same, alpha]
+
+ expect(filterDatasets(rows, DEFAULT_DATASET_CATALOG_QUERY).map((item) => item.selection_key)).toEqual([
+ 'dataset:named:alpha',
+ 'dataset:named:alpha-2',
+ 'dataset:named:beta',
+ ])
+ expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'name_desc' }).map((item) => item.selection_key)).toEqual([
+ 'dataset:named:beta',
+ 'dataset:named:alpha',
+ 'dataset:named:alpha-2',
+ ])
+ expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'logical_examples' }).map((item) => item.selection_key)).toEqual([
+ 'dataset:named:alpha',
+ 'dataset:named:alpha-2',
+ 'dataset:named:beta',
+ ])
+ expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort: 'logical_examples_desc' }).map((item) => item.selection_key)).toEqual([
+ 'dataset:named:beta',
+ 'dataset:named:alpha',
+ 'dataset:named:alpha-2',
+ ])
+ })
+
+ it.each<[DatasetSort, string[]]>([
+ ['logical_examples', ['zero', 'few', 'many', 'a-unknown', 'b-unknown']],
+ ['logical_examples_desc', ['many', 'few', 'zero', 'a-unknown', 'b-unknown']],
+ ])('keeps unknown logical-example counts last for %s', (sort: DatasetSort, expected: string[]) => {
+ const rows = [
+ makeDataset({ name: 'b-unknown', selection_key: 'dataset:named:b-unknown', logical_examples: null }),
+ makeDataset({ name: 'zero', selection_key: 'dataset:named:zero', logical_examples: 0 }),
+ makeDataset({ name: 'many', selection_key: 'dataset:named:many', logical_examples: 10 }),
+ makeDataset({ name: 'a-unknown', selection_key: 'dataset:named:a-unknown', logical_examples: null }),
+ makeDataset({ name: 'few', selection_key: 'dataset:named:few', logical_examples: 2 }),
+ ]
+
+ expect(filterDatasets(rows, { ...DEFAULT_DATASET_CATALOG_QUERY, sort }).map((item: DatasetInfo) => item.name))
+ .toEqual(expected)
+ })
+})
diff --git a/frontend/src/components/Datasets/datasetQuery.ts b/frontend/src/components/Datasets/datasetQuery.ts
new file mode 100644
index 0000000000..9774056b02
--- /dev/null
+++ b/frontend/src/components/Datasets/datasetQuery.ts
@@ -0,0 +1,144 @@
+import type { DatasetInfo } from '@/types'
+
+const SEARCH_PARAM = 'q'
+const MODALITY_PARAM = 'modality'
+const HARM_CATEGORY_PARAM = 'harm_category'
+const UNLABELED_PARAM = 'unlabeled'
+const SORT_PARAM = 'sort'
+
+export const DATASET_SORTS = [
+ 'name',
+ 'name_desc',
+ 'logical_examples_desc',
+ 'logical_examples',
+] as const
+
+export type DatasetSort = (typeof DATASET_SORTS)[number]
+
+export const DEFAULT_DATASET_SORT: DatasetSort = 'name'
+
+export const DATASET_SORT_LABELS: Record = {
+ name: 'Name (A-Z)',
+ name_desc: 'Name (Z-A)',
+ logical_examples_desc: 'Most examples',
+ logical_examples: 'Fewest examples',
+}
+
+export interface DatasetCatalogQuery {
+ readonly search: string
+ readonly modalities: readonly string[]
+ readonly harmCategories: readonly string[]
+ readonly unlabeled: boolean
+ readonly sort: DatasetSort
+}
+
+export const DEFAULT_DATASET_CATALOG_QUERY: DatasetCatalogQuery = {
+ search: '',
+ modalities: [],
+ harmCategories: [],
+ unlabeled: false,
+ sort: DEFAULT_DATASET_SORT,
+}
+
+export function isDatasetSort(value: string): value is DatasetSort {
+ return DATASET_SORTS.some((sort) => sort === value)
+}
+
+/** Reads catalog search, facets, and sort from the page query. */
+export function catalogQueryFromSearchParams(params: URLSearchParams): DatasetCatalogQuery {
+ const sortValue = params.get(SORT_PARAM)
+ return {
+ search: params.get(SEARCH_PARAM) ?? '',
+ modalities: params.getAll(MODALITY_PARAM),
+ harmCategories: params.getAll(HARM_CATEGORY_PARAM),
+ unlabeled: params.get(UNLABELED_PARAM) === 'true',
+ sort: sortValue != null && isDatasetSort(sortValue) ? sortValue : DEFAULT_DATASET_SORT,
+ }
+}
+
+/** Encodes catalog search, facets, and sort, omitting filters that are already the default. */
+export function catalogQueryToSearchParams(query: DatasetCatalogQuery): URLSearchParams {
+ const params = new URLSearchParams()
+ if (query.search) params.set(SEARCH_PARAM, query.search)
+ for (const modality of query.modalities) params.append(MODALITY_PARAM, modality)
+ for (const category of query.harmCategories) params.append(HARM_CATEGORY_PARAM, category)
+ if (query.unlabeled) params.set(UNLABELED_PARAM, 'true')
+ if (query.sort !== DEFAULT_DATASET_SORT) params.set(SORT_PARAM, query.sort)
+ return params
+}
+
+export function catalogQueryIsDefault(query: DatasetCatalogQuery): boolean {
+ return query.search === ''
+ && query.modalities.length === 0
+ && query.harmCategories.length === 0
+ && !query.unlabeled
+ && query.sort === DEFAULT_DATASET_SORT
+}
+
+function matchesSearch(name: string, search: string): boolean {
+ const query = search.trim().toLowerCase()
+ if (!query) return true
+ return name.toLowerCase().includes(query)
+}
+
+function matchesModalities(dataset: DatasetInfo, modalities: readonly string[]): boolean {
+ if (modalities.length === 0) return true
+ return modalities.some((modality) => dataset.modalities.includes(modality))
+}
+
+function matchesHarm(dataset: DatasetInfo, query: DatasetCatalogQuery): boolean {
+ const harmSelected = query.harmCategories.length > 0 || query.unlabeled
+ if (!harmSelected) return true
+ const categoryMatch = query.harmCategories.some((category) => dataset.harm_categories.includes(category))
+ const unlabeledMatch = query.unlabeled && dataset.has_unlabeled_harm_categories
+ return categoryMatch || unlabeledMatch
+}
+
+function countSort(sort: DatasetSort): 'asc' | 'desc' | null {
+ switch (sort) {
+ case 'logical_examples':
+ return 'asc'
+ case 'logical_examples_desc':
+ return 'desc'
+ default:
+ return null
+ }
+}
+
+function compareCounts(left: number | null, right: number | null, direction: 'asc' | 'desc'): number {
+ if (left == null && right == null) return 0
+ if (left == null) return 1
+ if (right == null) return -1
+ return direction === 'asc' ? left - right : right - left
+}
+
+function compareNames(left: DatasetInfo, right: DatasetInfo, direction: 'asc' | 'desc'): number {
+ const nameOrder = left.name.localeCompare(right.name, 'en', { sensitivity: 'base' })
+ const directed = direction === 'asc' ? nameOrder : -nameOrder
+ if (directed !== 0) return directed
+ return left.selection_key.localeCompare(right.selection_key, 'en')
+}
+
+/** Returns catalog rows matching the query. Unknown counts sort after known counts. */
+export function filterDatasets(
+ datasets: readonly DatasetInfo[],
+ query: DatasetCatalogQuery,
+): DatasetInfo[] {
+ const matched: DatasetInfo[] = []
+ for (const dataset of datasets) {
+ if (!matchesSearch(dataset.name, query.search)) continue
+ if (!matchesModalities(dataset, query.modalities)) continue
+ if (!matchesHarm(dataset, query)) continue
+ matched.push(dataset)
+ }
+
+ const countDirection = countSort(query.sort)
+ const nameDirection = query.sort === 'name_desc' ? 'desc' : 'asc'
+ return matched.sort((left, right) => {
+ if (countDirection) {
+ const countOrder = compareCounts(left.logical_examples, right.logical_examples, countDirection)
+ if (countOrder !== 0) return countOrder
+ }
+ return compareNames(left, right, countDirection ? 'asc' : nameDirection)
+ })
+}
diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx
index bc83c3e581..c4b4a0e7b1 100644
--- a/frontend/src/components/Sidebar/Navigation.test.tsx
+++ b/frontend/src/components/Sidebar/Navigation.test.tsx
@@ -142,6 +142,7 @@ describe("Navigation", () => {
"Chat",
"History",
"Scanner",
+ "Datasets",
"Registry",
"Configuration",
]);
@@ -185,6 +186,32 @@ describe("Navigation", () => {
);
});
+ it("renders the Datasets button", () => {
+ renderWithProvider( );
+ expect(screen.getByRole("button", { name: "Datasets" })).toBeInTheDocument();
+ });
+
+ it("calls onNavigate with 'datasets' when the datasets button is clicked", async () => {
+ const user = userEvent.setup();
+ const onNavigate = jest.fn();
+ renderWithProvider(
+
+ );
+
+ await user.click(screen.getByRole("button", { name: "Datasets" }));
+ expect(onNavigate).toHaveBeenCalledWith("datasets");
+ });
+
+ it("marks the datasets button current when it is the active view", () => {
+ renderWithProvider(
+
+ );
+ expect(screen.getByRole("button", { name: "Datasets" })).toHaveAttribute(
+ "aria-current",
+ "page"
+ );
+ });
+
it("renders one feedback button and forwards clicks to onOpenFeedback", async () => {
const user = userEvent.setup();
const onOpenFeedback = jest.fn();
diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx
index 2bfee46427..3112aebf6e 100644
--- a/frontend/src/components/Sidebar/Navigation.tsx
+++ b/frontend/src/components/Sidebar/Navigation.tsx
@@ -10,6 +10,7 @@ import {
import type { MenuCheckedValueChangeData, MenuCheckedValueChangeEvent } from '@fluentui/react-components'
import {
ChatRegular,
+ DatabaseRegular,
HomeRegular,
SettingsRegular,
HistoryRegular,
@@ -32,6 +33,7 @@ export type ViewName =
| 'registry'
| 'configuration'
| 'scenarios'
+ | 'datasets'
interface NavigationProps {
currentView: ViewName
@@ -112,6 +114,17 @@ export default function Navigation({
onClick={() => onNavigate('scenarios')}
/>
+ }
+ title="Datasets"
+ aria-label="Datasets"
+ aria-current={currentView === 'datasets' ? 'page' : undefined}
+ onClick={() => onNavigate('datasets')}
+ />
+
({
+ useRuntime: () => ({ ready: true, state: 'ready', generation }),
+}))
+
+jest.mock('@/services/api', () => ({
+ datasetsApi: {
+ listDatasets: jest.fn(),
+ },
+}))
+
+const mockListDatasets = datasetsApi.listDatasets as jest.Mock
+
+const FIRST: DatasetInfo = {
+ name: 'harmbench',
+ selection_key: 'dataset:named:harmbench',
+ loaded: true,
+ provider_available: false,
+ logical_examples: 2,
+ seed_pieces: 2,
+ objectives: 2,
+ modalities: ['text'],
+ harm_categories: ['hate'],
+ has_unlabeled_harm_categories: false,
+}
+
+const SECOND: DatasetInfo = {
+ ...FIRST,
+ name: 'other',
+ selection_key: 'dataset:named:other',
+}
+
+describe('useLoadedDatasets', () => {
+ beforeEach(() => {
+ generation = 'gen-1'
+ jest.clearAllMocks()
+ })
+
+ it('loads the catalog and replaces it when the runtime generation changes', async () => {
+ mockListDatasets
+ .mockResolvedValueOnce({ items: [FIRST] })
+ .mockResolvedValueOnce({ items: [SECOND] })
+
+ const { result, rerender } = renderHook(() => useLoadedDatasets())
+
+ await waitFor(() => expect(result.current.loading).toBe(false))
+ expect(result.current.datasets).toEqual([FIRST])
+ expect(result.current.error).toBeNull()
+ expect(mockListDatasets).toHaveBeenCalledTimes(1)
+ expect(mockListDatasets.mock.calls[0][0]).toEqual({ loaded_only: true })
+ const firstSignal = mockListDatasets.mock.calls[0][1] as AbortSignal
+ expect(firstSignal.aborted).toBe(false)
+
+ generation = 'gen-2'
+ rerender()
+
+ expect(result.current.loading).toBe(true)
+ expect(result.current.datasets).toEqual([])
+ expect(firstSignal.aborted).toBe(true)
+
+ await waitFor(() => expect(result.current.datasets).toEqual([SECOND]))
+ expect(result.current.loading).toBe(false)
+ expect(mockListDatasets).toHaveBeenCalledTimes(2)
+ const secondSignal = mockListDatasets.mock.calls[1][1] as AbortSignal
+ expect(secondSignal).not.toBe(firstSignal)
+ expect(secondSignal.aborted).toBe(false)
+ expect(mockListDatasets.mock.calls[1][0]).toEqual({ loaded_only: true })
+ })
+
+ it('aborts an in-flight request when the runtime generation changes without showing the cancellation', async () => {
+ mockListDatasets.mockImplementation((_query: unknown, signal: AbortSignal) => new Promise((_resolve, reject) => {
+ signal.addEventListener('abort', () => {
+ reject(new Error('canceled'))
+ })
+ }))
+
+ const { result, rerender } = renderHook(() => useLoadedDatasets())
+ await waitFor(() => expect(mockListDatasets).toHaveBeenCalledTimes(1))
+ const firstSignal = mockListDatasets.mock.calls[0][1] as AbortSignal
+
+ mockListDatasets.mockReturnValueOnce(new Promise(() => {}))
+ generation = 'gen-2'
+ rerender()
+
+ expect(firstSignal.aborted).toBe(true)
+ expect(result.current.loading).toBe(true)
+ expect(result.current.datasets).toEqual([])
+ await act(async () => {
+ await Promise.resolve()
+ })
+ expect(result.current.error).toBeNull()
+ await waitFor(() => expect(mockListDatasets).toHaveBeenCalledTimes(2))
+ expect((mockListDatasets.mock.calls[1][1] as AbortSignal).aborted).toBe(false)
+ })
+
+ it('aborts the catalog request when the subscriber unmounts', async () => {
+ let signal: AbortSignal | undefined
+ mockListDatasets.mockImplementation((_query: unknown, nextSignal: AbortSignal) => new Promise((_resolve, reject) => {
+ signal = nextSignal
+ nextSignal.addEventListener('abort', () => {
+ reject(new Error('canceled'))
+ })
+ }))
+
+ const { unmount } = renderHook(() => useLoadedDatasets())
+ await waitFor(() => expect(signal).toBeDefined())
+ unmount()
+
+ expect(signal?.aborted).toBe(true)
+ await act(async () => {
+ await Promise.resolve()
+ })
+ })
+})
diff --git a/frontend/src/hooks/useLoadedDatasets.ts b/frontend/src/hooks/useLoadedDatasets.ts
new file mode 100644
index 0000000000..698bb9641b
--- /dev/null
+++ b/frontend/src/hooks/useLoadedDatasets.ts
@@ -0,0 +1,66 @@
+import { useCallback, useEffect, useState } from 'react'
+
+import { useRuntime } from '@/hooks/useRuntime'
+import { datasetsApi } from '@/services/api'
+import { toApiError } from '@/services/errors'
+import type { DatasetInfo } from '@/types'
+
+interface LoadedDatasetsState {
+ readonly datasets: DatasetInfo[]
+ readonly loading: boolean
+ readonly error: string | null
+ readonly retry: () => void
+}
+
+/**
+ * Loads the memory-backed dataset catalog. The selection key is not a parameter:
+ * cards and the selection route look up keys in this response.
+ */
+export function useLoadedDatasets(): LoadedDatasetsState {
+ const { generation } = useRuntime()
+ const [datasets, setDatasets] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [refetchCount, setRefetchCount] = useState(0)
+ const [activeGeneration, setActiveGeneration] = useState(generation)
+
+ // Reset before paint so a selection key is not matched against the previous runtime's catalog.
+ if (activeGeneration !== generation) {
+ setActiveGeneration(generation)
+ setLoading(true)
+ setDatasets([])
+ setError(null)
+ }
+
+ useEffect(() => {
+ const controller = new AbortController()
+
+ const load = async (): Promise => {
+ try {
+ const response = await datasetsApi.listDatasets({ loaded_only: true }, controller.signal)
+ if (controller.signal.aborted) return
+ setDatasets(response.items)
+ setError(null)
+ } catch (err: unknown) {
+ if (controller.signal.aborted) return
+ setDatasets([])
+ setError(toApiError(err).detail)
+ } finally {
+ if (!controller.signal.aborted) setLoading(false)
+ }
+ }
+
+ void load()
+ return () => {
+ controller.abort()
+ }
+ }, [generation, refetchCount])
+
+ const retry = useCallback(() => {
+ setLoading(true)
+ setError(null)
+ setRefetchCount((count) => count + 1)
+ }, [])
+
+ return { datasets, loading, error, retry }
+}
diff --git a/frontend/src/services/api.test.ts b/frontend/src/services/api.test.ts
index dd1234862f..0b3de0615f 100644
--- a/frontend/src/services/api.test.ts
+++ b/frontend/src/services/api.test.ts
@@ -26,6 +26,7 @@ import {
attacksApi,
labelsApi,
scenariosApi,
+ datasetsApi,
} from "./api";
describe("api service", () => {
@@ -982,4 +983,35 @@ describe("api service", () => {
expect(apiClient.get).not.toHaveBeenCalled();
});
});
+
+ describe("datasetsApi", () => {
+ it("lists dataset summaries with loaded_only and does not send a selection key", async () => {
+ const mockResponse = {
+ data: {
+ items: [],
+ },
+ };
+ (apiClient.get as jest.Mock).mockResolvedValueOnce(mockResponse);
+
+ await expect(datasetsApi.listDatasets({ loaded_only: true })).resolves.toEqual({ items: [] });
+
+ expect(apiClient.get).toHaveBeenCalledTimes(1);
+ expect(apiClient.get).toHaveBeenCalledWith("/datasets", {
+ params: { loaded_only: true },
+ signal: undefined,
+ });
+ });
+
+ it("forwards an abort signal and still does not send a selection key", async () => {
+ const controller = new AbortController();
+ (apiClient.get as jest.Mock).mockResolvedValueOnce({ data: { items: [] } });
+
+ await datasetsApi.listDatasets({ loaded_only: true }, controller.signal);
+
+ expect(apiClient.get).toHaveBeenCalledWith("/datasets", {
+ params: { loaded_only: true },
+ signal: controller.signal,
+ });
+ });
+ });
});
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts
index ba376d76ef..6758ef71a8 100644
--- a/frontend/src/services/api.ts
+++ b/frontend/src/services/api.ts
@@ -28,6 +28,7 @@ import type {
CreateAttackResponse,
AttackSummary,
AttackListResponse,
+ DatasetListResponse,
ConversationMessagesResponse,
AddMessageRequest,
AddMessageResponse,
@@ -575,4 +576,21 @@ export const scenariosApi = {
return response.data
},
}
+
+export const datasetsApi = {
+ /**
+ * Lists dataset summaries. Counts, modalities, and harm categories on the
+ * cards come from this payload; callers do not page seeds to compute them.
+ */
+ listDatasets: async (
+ { loaded_only }: { readonly loaded_only: boolean },
+ signal?: AbortSignal,
+ ): Promise => {
+ const response = await apiClient.get('/datasets', {
+ params: { loaded_only },
+ signal,
+ })
+ return response.data
+ },
+}
import type { RuntimeReadiness, RuntimeStatus } from '@/types'
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 0c7920f127..fcbe1cf014 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -815,6 +815,28 @@ export interface ChangeMainConversationResponse {
conversation_id: string
}
+/**
+ * Wire shape of `DatasetInfo` from `GET /api/datasets`.
+ * Null counts mean the total is unknown, not zero.
+ */
+export interface DatasetInfo {
+ name: string
+ selection_key: string
+ loaded: boolean
+ provider_available: boolean
+ logical_examples: number | null
+ seed_pieces: number | null
+ objectives: number | null
+ modalities: string[]
+ harm_categories: string[]
+ has_unlabeled_harm_categories: boolean
+}
+
+/** Wire shape of `DatasetListResponse` from `GET /api/datasets`. */
+export interface DatasetListResponse {
+ items: DatasetInfo[]
+}
+
// --- Scenarios ---
export interface RegisteredScenario {
diff --git a/frontend/src/utils/routeParams.test.ts b/frontend/src/utils/routeParams.test.ts
index 21a8af4807..e2a0b84675 100644
--- a/frontend/src/utils/routeParams.test.ts
+++ b/frontend/src/utils/routeParams.test.ts
@@ -1,10 +1,14 @@
import {
attackConversationRoutePath,
attackRoutePath,
+ catalogPathFromNavigationState,
+ DATASET_DETAIL_PATH,
+ datasetDetailPath,
routerPathParamValue,
scenarioRunAttackRoutePath,
scenarioRunProvenance,
scenarioRunRoutePath,
+ selectionKeyFromSearchParams,
} from './routeParams'
const SCENARIO_RESULT_ID = '123e4567-e89b-12d3-a456-426614174000'
@@ -64,3 +68,47 @@ describe('scenario run provenance routes', () => {
)
})
})
+
+const SPECIAL_KEYS = [
+ 'dataset:unnamed',
+ 'dataset:named:__unnamed__',
+ 'dataset:named:my dataset',
+ 'dataset:named:a/b',
+ 'dataset:named:a?b',
+ 'dataset:named:a#b',
+ 'dataset:named:a&b',
+ 'dataset:named:100%',
+ 'dataset:named:データ',
+]
+
+describe('dataset detail paths', () => {
+ it.each(SPECIAL_KEYS)('round-trips %s through the detail query without putting it in the path', (selectionKey: string) => {
+ const url = new URL(datasetDetailPath(selectionKey), 'http://localhost')
+
+ expect(url.pathname).toBe(DATASET_DETAIL_PATH)
+ expect(url.pathname.split('/')).not.toContain(selectionKey)
+ expect(url.hash).toBe('')
+ expect(Array.from(url.searchParams.keys())).toEqual(['selection_key'])
+ expect(url.searchParams.get('selection_key')).toBe(selectionKey)
+ expect(selectionKeyFromSearchParams(url.searchParams)).toBe(selectionKey)
+ expect(datasetDetailPath(selectionKey).startsWith(`${DATASET_DETAIL_PATH}?`)).toBe(true)
+ })
+
+ it('treats a missing or repeated selection key as no selection', () => {
+ expect(selectionKeyFromSearchParams(new URLSearchParams())).toBeNull()
+ expect(selectionKeyFromSearchParams(new URLSearchParams('selection_key=one&selection_key=two'))).toBeNull()
+ expect(selectionKeyFromSearchParams(new URLSearchParams('selection_key='))).toBe('')
+ })
+
+ it('keeps a same-app catalog path and rejects anything else', () => {
+ const filtered = '/datasets?q=policy&modality=text&sort=logical_examples_desc'
+ expect(catalogPathFromNavigationState({ catalogPath: filtered })).toBe(filtered)
+ expect(catalogPathFromNavigationState({ catalogPath: '/datasets' })).toBe('/datasets')
+ expect(catalogPathFromNavigationState(null)).toBe('/datasets')
+ expect(catalogPathFromNavigationState({})).toBe('/datasets')
+ expect(catalogPathFromNavigationState({ catalogPath: 'https://evil.example/datasets' })).toBe('/datasets')
+ expect(catalogPathFromNavigationState({ catalogPath: '//evil.example/datasets' })).toBe('/datasets')
+ expect(catalogPathFromNavigationState({ catalogPath: '/datasets/detail?selection_key=dataset:unnamed' })).toBe('/datasets')
+ expect(catalogPathFromNavigationState({ catalogPath: '/datasets/../admin' })).toBe('/datasets')
+ })
+})
diff --git a/frontend/src/utils/routeParams.ts b/frontend/src/utils/routeParams.ts
index 8af3d0ccbf..84824570fb 100644
--- a/frontend/src/utils/routeParams.ts
+++ b/frontend/src/utils/routeParams.ts
@@ -64,3 +64,69 @@ function appendScenarioRunProvenance(path: string, scenarioResultId?: string | n
})
return `${path}?${searchParams.toString()}`
}
+
+/** Catalog route. Dataset identity is never a path segment. */
+export const DATASETS_PATH = '/datasets'
+
+/** Selection contract for the prompt table (#2745). The key stays in the query. */
+export const DATASET_DETAIL_PATH = '/datasets/detail'
+
+const SELECTION_KEY_PARAM = 'selection_key'
+
+/**
+ * Reads the opaque selection key. A missing or repeated key is not a selection.
+ * The returned string is for catalog lookup only and is not sent back to the server.
+ */
+export function selectionKeyFromSearchParams(params: URLSearchParams): string | null {
+ const values = params.getAll(SELECTION_KEY_PARAM)
+ if (values.length !== 1) return null
+ return values[0]
+}
+
+/**
+ * Builds `/datasets/detail?selection_key=` with URLSearchParams.
+ * The key is never interpolated into the path.
+ */
+export function datasetDetailPath(selectionKey: string): string {
+ const params = new URLSearchParams()
+ params.set(SELECTION_KEY_PARAM, selectionKey)
+ return `${DATASET_DETAIL_PATH}?${params.toString()}`
+}
+
+const CATALOG_PATH_STATE_KEY = 'catalogPath'
+
+/**
+ * Reads a same-app catalog return path from navigation state.
+ * Anything that is not exactly `/datasets` plus an optional query falls back
+ * to the catalog root so this value cannot be used as an open redirect.
+ */
+export function catalogPathFromNavigationState(state: unknown): string {
+ if (state == null || typeof state !== 'object' || !(CATALOG_PATH_STATE_KEY in state)) {
+ return DATASETS_PATH
+ }
+ const candidate = (state as { catalogPath?: unknown }).catalogPath
+ if (typeof candidate !== 'string' || !isCatalogReturnPath(candidate)) {
+ return DATASETS_PATH
+ }
+ return candidate
+}
+
+function isCatalogReturnPath(value: string): boolean {
+ if (value !== DATASETS_PATH && !value.startsWith(`${DATASETS_PATH}?`)) return false
+ if (value.includes('\\') || value.includes('#') || value.includes('://')) return false
+ for (const character of value) {
+ const code = character.charCodeAt(0)
+ if (code <= 31 || code === 127) return false
+ }
+ let url: URL
+ try {
+ url = new URL(value, 'http://pyrit.local')
+ } catch {
+ return false
+ }
+ return url.origin === 'http://pyrit.local'
+ && url.username === ''
+ && url.password === ''
+ && url.pathname === DATASETS_PATH
+ && url.hash === ''
+}