diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index 3cab6378c1..aaa670c368 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -61,8 +61,13 @@ test.describe("Accessibility", () => { }); test("should be navigable with keyboard", async ({ page }) => { - // Tab to the first interactive element - await page.keyboard.press("Tab"); + // Wait for the sidebar to render so there is a focusable element for Tab + // to land on, and dispatch the Tab through `body` (rather than the bare + // keyboard) to guarantee the document has focus when the keystroke fires. + // Without both, Chromium sometimes leaves `:focus` empty under parallel + // worker load. + await expect(page.getByTitle("Home")).toBeVisible(); + await page.locator("body").press("Tab"); const focused = page.locator(":focus"); await expect(focused).toBeVisible(); @@ -145,7 +150,9 @@ test.describe("Visual Consistency", () => { test("should render without layout shifts", async ({ page }) => { await page.goto("/"); - // Wait for initial render + // Wait for initial render then navigate to chat to measure the chat ribbon + await expect(page.getByTitle("Chat")).toBeVisible(); + await page.getByTitle("Chat").click(); await expect(page.getByText("PyRIT Attack")).toBeVisible(); // Take measurements diff --git a/frontend/e2e/api.spec.ts b/frontend/e2e/api.spec.ts index b0b390e514..f0daa144b2 100644 --- a/frontend/e2e/api.spec.ts +++ b/frontend/e2e/api.spec.ts @@ -141,6 +141,6 @@ test.describe("Error Handling", () => { await page.goto("/"); // UI should be responsive even while APIs are delayed - await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 }); }); }); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 0938dcafa0..7af49ea854 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -154,14 +154,18 @@ test.describe("Application Smoke Tests", () => { }); test("should display PyRIT header", async ({ page }) => { + await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 }); + await page.getByTitle("Chat").click(); await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 }); }); test("should have New Attack button", async ({ page }) => { + await page.getByTitle("Chat").click(); await expect(page.getByRole("button", { name: /new attack/i })).toBeVisible(); }); test("should show 'no target' hint when no target is active", async ({ page }) => { + await page.getByTitle("Chat").click(); await expect(page.getByTestId("no-target-banner")).toBeVisible(); }); }); @@ -169,7 +173,7 @@ test.describe("Application Smoke Tests", () => { test.describe("Theme Toggle", () => { test("should toggle dark/light theme", async ({ page }) => { await page.goto("/"); - await expect(page.getByText("PyRIT Attack")).toBeVisible({ timeout: 10000 }); + await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000 }); // The app defaults to dark mode, so the toggle button title should say "Light Mode" const themeBtn = page.getByTitle("Light Mode"); @@ -293,6 +297,7 @@ test.describe("Multiple Messages", () => { test.describe("Chat without target", () => { test("should disable input when no target is active", async ({ page }) => { await page.goto("/"); + await page.getByTitle("Chat").click(); // The no-target-banner should be visible because no target is active await expect(page.getByTestId("no-target-banner")).toBeVisible(); diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index cf2ee36ce8..b823227d40 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -246,6 +246,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { // Start in chat — no-target-banner should be visible await page.goto("/"); + await page.getByTitle("Chat").click(); await expect(page.getByTestId("no-target-banner")).toBeVisible(); // Go to config, set a target diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts index 81e783a346..83a165a354 100644 --- a/frontend/e2e/errors.spec.ts +++ b/frontend/e2e/errors.spec.ts @@ -324,7 +324,7 @@ test.describe("Error: connection banner on health failure", () => { }) => { // Let the page load normally first await page.goto("/"); - await expect(page.getByText("PyRIT Attack")).toBeVisible({ + await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000, }); @@ -372,7 +372,7 @@ test.describe("Error: connection banner recovery", () => { }); await page.goto("/"); - await expect(page.getByText("PyRIT Attack")).toBeVisible({ + await expect(page.getByTitle("Chat")).toBeVisible({ timeout: 10000, }); diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index 34fc91e6d3..516f469e41 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -61,6 +61,9 @@ jest.mock("./components/Layout/MainLayout", () => { + @@ -188,6 +191,41 @@ jest.mock("./components/History/AttackHistory", () => { }; }); +jest.mock("./components/Home/Home", () => { + const MockHome = ({ + activeTarget, + onNavigate, + onOpenAttack, + labels, + }: { + activeTarget: unknown; + onNavigate: (view: string) => void; + onOpenAttack: (attackResultId: string) => void; + labels: Record; + }) => { + return ( +
+ {activeTarget ? "yes" : "no"} + {JSON.stringify(labels)} + + +
+ ); + }; + MockHome.displayName = "MockHome"; + return { + __esModule: true, + default: MockHome, + }; +}); + describe("App", () => { beforeEach(() => { jest.clearAllMocks(); @@ -197,7 +235,7 @@ describe("App", () => { it("renders with FluentProvider and MainLayout", () => { render(); expect(screen.getByTestId("main-layout")).toBeInTheDocument(); - expect(screen.getByTestId("chat-window")).toBeInTheDocument(); + expect(screen.getByTestId("home-view")).toBeInTheDocument(); }); it("starts in dark mode", () => { @@ -229,9 +267,21 @@ describe("App", () => { ); }); - it("starts in chat view", () => { + it("starts in home view", () => { render(); + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "home" + ); + expect(screen.getByTestId("home-view")).toBeInTheDocument(); + }); + + it("switches to chat view", () => { + render(); + + fireEvent.click(screen.getByTestId("nav-chat")); + expect(screen.getByTestId("main-layout")).toHaveAttribute( "data-current-view", "chat" @@ -264,6 +314,7 @@ describe("App", () => { it("sets conversationId from chat window", () => { render(); + fireEvent.click(screen.getByTestId("nav-chat")); expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"); fireEvent.click(screen.getByTestId("set-conversation")); @@ -273,6 +324,7 @@ describe("App", () => { it("clears conversationId on new attack", () => { render(); + fireEvent.click(screen.getByTestId("nav-chat")); fireEvent.click(screen.getByTestId("set-conversation")); expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123"); @@ -283,7 +335,8 @@ describe("App", () => { it("sets active target from config page and passes to chat", () => { render(); - // No target initially + // Switch to chat and confirm no target initially + fireEvent.click(screen.getByTestId("nav-chat")); expect(screen.getByTestId("has-target")).toHaveTextContent("no"); // Switch to config and set target @@ -322,6 +375,36 @@ describe("App", () => { await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("attack-conv-1")); }); + it("opens attack from home and switches to chat", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-home-attack", + conversation_id: "home-conv-1", + labels: { operator: "roakey" }, + }); + render(); + + fireEvent.click(screen.getByTestId("home-open-attack")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "chat" + ); + await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-home-attack")); + await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("home-conv-1")); + }); + + it("navigates to config from the home view", () => { + render(); + + fireEvent.click(screen.getByTestId("home-go-config")); + + expect(screen.getByTestId("main-layout")).toHaveAttribute( + "data-current-view", + "config" + ); + expect(screen.getByTestId("target-config")).toBeInTheDocument(); + }); + it("handles failed attack open gracefully", async () => { mockGetAttack.mockRejectedValue(new Error("Not found")); render(); @@ -349,6 +432,9 @@ describe("App", () => { expect(mockedVersionApi.getVersion).toHaveBeenCalled(); }); + // Switch to chat to inspect labels + fireEvent.click(screen.getByTestId("nav-chat")); + await waitFor(() => { expect(screen.getByTestId("labels-operator")).toHaveTextContent("default_user"); expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"'); @@ -364,9 +450,12 @@ describe("App", () => { render(); + // Home receives the same labels prop — assert there to avoid racing the + // async initLabels effect against a view-change re-render. await waitFor(() => { - expect(screen.getByTestId("labels-operator")).toHaveTextContent("test.user"); - expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"'); + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"operator":"test.user"'); + expect(labels).toContain('"custom":"value"'); }); }); @@ -380,8 +469,9 @@ describe("App", () => { render(); await waitFor(() => { - expect(screen.getByTestId("labels-operator")).toHaveTextContent("override_user"); - expect(screen.getByTestId("labels-json")).toHaveTextContent('"custom":"value"'); + const labels = screen.getByTestId("home-labels-json").textContent ?? ""; + expect(labels).toContain('"operator":"override_user"'); + expect(labels).toContain('"custom":"value"'); }); }); @@ -401,6 +491,8 @@ describe("App", () => { it("sets active conversation when onSelectConversation is called", () => { render(); + fireEvent.click(screen.getByTestId("nav-chat")); + // First create a conversation to have an attack fireEvent.click(screen.getByTestId("set-conversation")); expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b1846647ad..98c21b9a7e 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,6 +3,7 @@ import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-com import { useMsal } from '@azure/msal-react' import MainLayout from './components/Layout/MainLayout' import ChatWindow from './components/Chat/ChatWindow' +import Home from './components/Home/Home' import TargetConfig from './components/Config/TargetConfig' import AttackHistory from './components/History/AttackHistory' import { DEFAULT_HISTORY_FILTERS } from './components/History/historyFilters' @@ -39,7 +40,7 @@ function ConnectionBannerContainer() { function App() { const { instance } = useMsal() const [isDarkMode, setIsDarkMode] = useState(true) - const [currentView, setCurrentView] = useState('chat') + const [currentView, setCurrentView] = useState('home') const [activeTarget, setActiveTarget] = useState(null) const [globalLabels, setGlobalLabels] = useState>({ ...DEFAULT_GLOBAL_LABELS }) /** True while loading a historical attack from the history view */ @@ -175,6 +176,15 @@ function App() { onToggleTheme={toggleTheme} isDarkMode={isDarkMode} > + {currentView === 'home' && ( + + )} {currentView === 'chat' && ( { ); - expect(document.body).toBeTruthy(); + expect( + screen.getByText("There are no messages in this conversation yet.") + ).toBeInTheDocument(); }); it("should render all messages", () => { diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index 192030bb1f..066fd3cf50 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -144,9 +144,8 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver if (messages.length === 0) { return (
- Welcome to PyRIT - Start a conversation to test AI safety and robustness + There are no messages in this conversation yet.
) diff --git a/frontend/src/components/Home/Home.styles.ts b/frontend/src/components/Home/Home.styles.ts new file mode 100644 index 0000000000..a8b0375580 --- /dev/null +++ b/frontend/src/components/Home/Home.styles.ts @@ -0,0 +1,174 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +export const useHomeStyles = makeStyles({ + root: { + flex: 1, + overflowY: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + }, + container: { + maxWidth: '1100px', + margin: '0 auto', + padding: `${tokens.spacingVerticalXXL} ${tokens.spacingHorizontalXXL}`, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXL, + }, + hero: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + heroTitle: { + color: tokens.colorBrandForeground1, + }, + heroSubtitle: { + color: tokens.colorNeutralForeground3, + }, + setupGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', + gap: tokens.spacingHorizontalL, + }, + card: { + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + padding: tokens.spacingHorizontalL, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalM, + }, + cardHeader: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + }, + cardIcon: { + fontSize: '24px', + color: tokens.colorBrandForeground1, + display: 'inline-flex', + }, + cardBody: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + labelsRow: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + minHeight: '32px', + }, + cardFooter: { + display: 'flex', + justifyContent: 'flex-end', + gap: tokens.spacingHorizontalS, + }, + targetSummary: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXXS, + }, + targetName: { + fontWeight: tokens.fontWeightSemibold, + }, + targetMeta: { + color: tokens.colorNeutralForeground3, + }, + emptyHint: { + color: tokens.colorNeutralForeground3, + fontStyle: 'italic', + }, + sectionHeader: { + display: 'flex', + alignItems: 'baseline', + justifyContent: 'space-between', + marginBottom: tokens.spacingVerticalM, + }, + operationsGrid: { + display: 'grid', + gridTemplateColumns: 'repeat(auto-fill, minmax(320px, 1fr))', + gap: tokens.spacingHorizontalL, + }, + operationCard: { + backgroundColor: tokens.colorNeutralBackground1, + border: `1px solid ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + padding: tokens.spacingHorizontalL, + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalS, + }, + operationHeader: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: tokens.spacingHorizontalS, + }, + operationName: { + fontWeight: tokens.fontWeightSemibold, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + }, + operationMeta: { + color: tokens.colorNeutralForeground3, + }, + attackList: { + display: 'flex', + flexDirection: 'column', + gap: tokens.spacingVerticalXS, + marginTop: tokens.spacingVerticalXS, + }, + attackRow: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, + borderRadius: tokens.borderRadiusMedium, + backgroundColor: 'transparent', + border: 'none', + cursor: 'pointer', + width: '100%', + textAlign: 'left', + color: 'inherit', + ':hover': { + backgroundColor: tokens.colorNeutralBackground1Hover, + }, + ':focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '1px', + }, + }, + attackPreview: { + flex: 1, + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + color: tokens.colorNeutralForeground2, + }, + attackTimestamp: { + color: tokens.colorNeutralForeground3, + fontSize: tokens.fontSizeBase200, + whiteSpace: 'nowrap', + }, + loadingState: { + display: 'flex', + justifyContent: 'center', + padding: tokens.spacingVerticalXXL, + }, + emptyOperations: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: tokens.spacingVerticalS, + padding: tokens.spacingVerticalXXL, + color: tokens.colorNeutralForeground3, + backgroundColor: tokens.colorNeutralBackground1, + border: `1px dashed ${tokens.colorNeutralStroke2}`, + borderRadius: tokens.borderRadiusLarge, + }, +}) diff --git a/frontend/src/components/Home/Home.test.tsx b/frontend/src/components/Home/Home.test.tsx new file mode 100644 index 0000000000..4f55cce73f --- /dev/null +++ b/frontend/src/components/Home/Home.test.tsx @@ -0,0 +1,187 @@ +/** + * Copyright (c) Microsoft Corporation. + * Licensed under the MIT license. + */ + +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { FluentProvider, webLightTheme } from "@fluentui/react-components"; +import Home from "./Home"; +import { attacksApi } from "../../services/api"; +import type { AttackSummary, TargetInstance } from "../../types"; + +jest.mock("../../services/api", () => ({ + attacksApi: { + listAttacks: jest.fn(), + }, + labelsApi: { + getLabels: jest.fn().mockResolvedValue({ source: "attacks", labels: {} }), + }, +})); + +const mockListAttacks = attacksApi.listAttacks as jest.Mock; + +const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + {children} +); + +function makeAttack(overrides: Partial = {}): AttackSummary { + return { + attack_result_id: "ar-1", + conversation_id: "conv-1", + attack_type: "TestAttack", + converters: [], + outcome: "success", + last_message_preview: "preview", + message_count: 1, + related_conversation_ids: [], + labels: { operator: "alice", operation: "op_alpha" }, + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + ...overrides, + }; +} + +const defaultLabels: Record = { operator: "alice", operation: "op_alpha" }; + +const defaultProps = { + labels: defaultLabels, + onLabelsChange: jest.fn(), + activeTarget: null as TargetInstance | null, + onNavigate: jest.fn(), + onOpenAttack: jest.fn(), +}; + +describe("Home", () => { + beforeEach(() => { + jest.clearAllMocks(); + mockListAttacks.mockResolvedValue({ + items: [], + pagination: { has_more: false, next_cursor: null }, + }); + }); + + it("renders the welcome hero", async () => { + render(); + expect(screen.getByText(/welcome to co-pyrit/i)).toBeInTheDocument(); + await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); + }); + + it("shows an empty target hint when no target is set", async () => { + render(); + expect(screen.getByTestId("home-target-empty")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /configure a target/i })).toBeInTheDocument(); + await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); + }); + + it("renders active target summary and 'Manage targets' button when a target is set", async () => { + const target: TargetInstance = { + target_registry_name: "my_target", + target_type: "OpenAIChatTarget", + endpoint: "https://example.com", + model_name: "gpt-test", + }; + render(); + expect(screen.getByTestId("home-target-active")).toHaveTextContent("gpt-test"); + expect(screen.getByRole("button", { name: /manage targets/i })).toBeInTheDocument(); + await waitFor(() => expect(mockListAttacks).toHaveBeenCalled()); + }); + + it("navigates to config when 'Configure a target' is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + render(); + await user.click(screen.getByTestId("home-configure-target-btn")); + expect(onNavigate).toHaveBeenCalledWith("config"); + }); + + it("shows the empty state when there are no attacks", async () => { + render(); + expect(await screen.findByTestId("home-empty")).toBeInTheDocument(); + expect(screen.getByText(/no attacks yet/i)).toBeInTheDocument(); + }); + + it("navigates to chat when 'Go to chat' is clicked from the empty state", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + render(); + const btn = await screen.findByTestId("home-start-attack-btn"); + await user.click(btn); + expect(onNavigate).toHaveBeenCalledWith("chat"); + }); + + it("renders recent operations grouped by operation label", async () => { + const now = Date.now(); + mockListAttacks.mockResolvedValue({ + items: [ + makeAttack({ + attack_result_id: "ar-1", + labels: { operator: "alice", operation: "op_alpha" }, + updated_at: new Date(now).toISOString(), + }), + makeAttack({ + attack_result_id: "ar-2", + labels: { operator: "alice", operation: "op_alpha" }, + updated_at: new Date(now - 60_000).toISOString(), + }), + makeAttack({ + attack_result_id: "ar-3", + labels: { operator: "alice", operation: "op_beta" }, + updated_at: new Date(now - 120_000).toISOString(), + }), + ], + pagination: { has_more: false, next_cursor: null }, + }); + + render(); + expect(await screen.findByTestId("home-operation-op_alpha")).toBeInTheDocument(); + expect(screen.getByTestId("home-operation-op_beta")).toBeInTheDocument(); + // op_alpha has 2 attacks + expect(screen.getByTestId("home-operation-op_alpha")).toHaveTextContent(/2 attacks/i); + // op_beta has 1 attack + expect(screen.getByTestId("home-operation-op_beta")).toHaveTextContent(/1 attack/); + }); + + it("groups attacks with no operation label under '(no operation)'", async () => { + mockListAttacks.mockResolvedValue({ + items: [ + makeAttack({ + attack_result_id: "ar-x", + labels: { operator: "alice" }, + }), + ], + pagination: { has_more: false, next_cursor: null }, + }); + + render(); + expect(await screen.findByTestId("home-operation-unlabeled")).toHaveTextContent(/no operation/i); + }); + + it("calls onOpenAttack when a recent attack row is clicked", async () => { + const user = userEvent.setup(); + const onOpenAttack = jest.fn(); + mockListAttacks.mockResolvedValue({ + items: [makeAttack({ attack_result_id: "ar-click" })], + pagination: { has_more: false, next_cursor: null }, + }); + + render(); + const row = await screen.findByTestId("home-open-attack-ar-click"); + await user.click(row); + expect(onOpenAttack).toHaveBeenCalledWith("ar-click"); + }); + + it("renders error message when listAttacks fails", async () => { + mockListAttacks.mockRejectedValueOnce(new Error("boom")); + render(); + expect(await screen.findByTestId("home-error")).toBeInTheDocument(); + }); + + it("navigates to history when 'View all history' is clicked", async () => { + const user = userEvent.setup(); + const onNavigate = jest.fn(); + render(); + await user.click(screen.getByTestId("home-view-all-history-btn")); + expect(onNavigate).toHaveBeenCalledWith("history"); + }); +}); diff --git a/frontend/src/components/Home/Home.tsx b/frontend/src/components/Home/Home.tsx new file mode 100644 index 0000000000..c79fe76327 --- /dev/null +++ b/frontend/src/components/Home/Home.tsx @@ -0,0 +1,295 @@ +import { useEffect, useMemo, useState } from 'react' +import { + Badge, + Button, + MessageBar, + MessageBarBody, + Spinner, + Text, + tokens, +} from '@fluentui/react-components' +import { + ArrowRightRegular, + CheckmarkCircleRegular, + DismissCircleRegular, + ErrorCircleRegular, + QuestionCircleRegular, + TagMultipleRegular, + TargetRegular, +} from '@fluentui/react-icons' +import LabelsBar from '../Labels/LabelsBar' +import { attacksApi } from '../../services/api' +import { toApiError } from '../../services/errors' +import type { AttackSummary, TargetInstance } from '../../types' +import type { ViewName } from '../Sidebar/Navigation' +import { useHomeStyles } from './Home.styles' + +const RECENT_ATTACKS_LIMIT = 50 +const MAX_OPERATIONS = 5 +const MAX_ATTACKS_PER_OPERATION = 3 +const NO_OPERATION_KEY = '__no_operation__' + +const OUTCOME_ICONS: Record = { + success: , + failure: , + error: , + undetermined: , +} + +interface HomeProps { + labels: Record + onLabelsChange: (labels: Record) => void + activeTarget: TargetInstance | null + onNavigate: (view: ViewName) => void + onOpenAttack: (attackResultId: string) => void +} + +interface OperationGroup { + name: string + isUnlabeled: boolean + attacks: AttackSummary[] + lastActivity: number +} + +function groupAttacksByOperation(attacks: AttackSummary[]): OperationGroup[] { + const groups = new Map() + + for (const attack of attacks) { + const opLabel = attack.labels?.operation + const isUnlabeled = !opLabel + const key = isUnlabeled ? NO_OPERATION_KEY : opLabel + const updatedAt = new Date(attack.updated_at).getTime() + + const existing = groups.get(key) + if (existing) { + existing.attacks.push(attack) + if (updatedAt > existing.lastActivity) { + existing.lastActivity = updatedAt + } + } else { + groups.set(key, { + name: isUnlabeled ? '(no operation)' : opLabel, + isUnlabeled, + attacks: [attack], + lastActivity: updatedAt, + }) + } + } + + return Array.from(groups.values()).sort((a, b) => b.lastActivity - a.lastActivity) +} + +function formatRelativeTime(iso: string): string { + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return '' + const diffMs = Date.now() - then + const seconds = Math.round(diffMs / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.round(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.round(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.round(hours / 24) + if (days < 7) return `${days}d ago` + return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) +} + +function targetDisplayName(target: TargetInstance): string { + return target.model_name || target.target_registry_name || target.target_type +} + +export default function Home({ + labels, + onLabelsChange, + activeTarget, + onNavigate, + onOpenAttack, +}: HomeProps) { + const styles = useHomeStyles() + const [attacks, setAttacks] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let ignore = false + setLoading(true) + setError(null) + attacksApi + .listAttacks({ limit: RECENT_ATTACKS_LIMIT }) + .then(resp => { + if (ignore) return + setAttacks(resp.items.map(item => ({ ...item, labels: item.labels ?? {} }))) + }) + .catch(err => { + if (ignore) return + setAttacks([]) + setError(toApiError(err).detail) + }) + .finally(() => { + if (!ignore) setLoading(false) + }) + return () => { ignore = true } + }, []) + + const operations = useMemo( + () => groupAttacksByOperation(attacks).slice(0, MAX_OPERATIONS), + [attacks], + ) + + return ( +
+
+
+ + Welcome to Co-PyRIT + + + Set your labels, pick a target, and start a new attack — or jump back into a recent operation. + +
+ +
+
+
+ + Labels +
+
+ + Labels (especially operator and operation) are stored on + every attack so you can find them later. Update the placeholders before you run anything real. + +
+ +
+
+
+ +
+
+ + Target +
+
+ {activeTarget ? ( +
+ {targetDisplayName(activeTarget)} + + {activeTarget.target_type} + {activeTarget.endpoint ? ` · ${activeTarget.endpoint}` : ''} + +
+ ) : ( + + No target selected. Pick one to send prompts. + + )} +
+
+ +
+
+
+ +
+
+ Recent operations + +
+ + {loading ? ( +
+ +
+ ) : error ? ( + + {error} + + ) : operations.length === 0 ? ( +
+ No attacks yet + + Configure a target and start a new attack from the Chat tab. + + +
+ ) : ( +
+ {operations.map(op => { + const visibleAttacks = op.attacks.slice(0, MAX_ATTACKS_PER_OPERATION) + const hiddenCount = op.attacks.length - visibleAttacks.length + const key = op.isUnlabeled ? NO_OPERATION_KEY : op.name + return ( +
+
+ + {op.name} + + + {op.attacks.length} {op.attacks.length === 1 ? 'attack' : 'attacks'} + +
+ + Last activity {formatRelativeTime(new Date(op.lastActivity).toISOString())} + +
+ {visibleAttacks.map(attack => ( + + ))} + {hiddenCount > 0 && ( + + +{hiddenCount} more in history + + )} +
+
+ ) + })} +
+ )} +
+
+
+ ) +} diff --git a/frontend/src/components/Sidebar/Navigation.test.tsx b/frontend/src/components/Sidebar/Navigation.test.tsx index 2cf4ab8cc7..aa4c46a5b6 100644 --- a/frontend/src/components/Sidebar/Navigation.test.tsx +++ b/frontend/src/components/Sidebar/Navigation.test.tsx @@ -23,6 +23,24 @@ describe("Navigation", () => { jest.clearAllMocks(); }); + it("renders the home button", () => { + renderWithProvider(); + + const homeButton = screen.getByTitle("Home"); + expect(homeButton).toBeInTheDocument(); + expect(homeButton).not.toBeDisabled(); + }); + + it("calls onNavigate with 'home' when home button is clicked", () => { + const onNavigate = jest.fn(); + renderWithProvider( + + ); + + fireEvent.click(screen.getByTitle("Home")); + expect(onNavigate).toHaveBeenCalledWith("home"); + }); + it("renders the chat button", () => { renderWithProvider(); diff --git a/frontend/src/components/Sidebar/Navigation.tsx b/frontend/src/components/Sidebar/Navigation.tsx index e5cb4be802..f323272b90 100644 --- a/frontend/src/components/Sidebar/Navigation.tsx +++ b/frontend/src/components/Sidebar/Navigation.tsx @@ -3,6 +3,7 @@ import { } from '@fluentui/react-components' import { ChatRegular, + HomeRegular, SettingsRegular, HistoryRegular, WeatherMoonRegular, @@ -10,7 +11,7 @@ import { } from '@fluentui/react-icons' import { useNavigationStyles } from './Navigation.styles' -export type ViewName = 'chat' | 'history' | 'config' +export type ViewName = 'home' | 'chat' | 'history' | 'config' interface NavigationProps { currentView: ViewName @@ -24,6 +25,16 @@ export default function Navigation({ currentView, onNavigate, onToggleTheme, isD return (
+