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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 0 additions & 44 deletions frontend/src/components/Chat/MessageList.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -241,50 +241,6 @@ export const useMessageListStyles = makeStyles({
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
scoreSurface: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXS,
width: 'min(360px, calc(100vw - 64px))',
minWidth: 0,
maxWidth: '100%',
},
scoreRow: {
display: 'flex',
alignItems: 'flex-start',
columnGap: tokens.spacingHorizontalS,
width: '100%',
minWidth: 0,
},
scoreLabel: {
flex: '0 0 72px',
color: tokens.colorNeutralForeground2,
},
scoreValue: {
display: 'block',
flex: '0 1 auto',
minWidth: 0,
maxWidth: '100%',
height: 'auto',
whiteSpace: 'normal',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
},
scoreRationale: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXXS,
marginTop: tokens.spacingVerticalXS,
paddingTop: tokens.spacingVerticalXS,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
},
scoreRationaleText: {
color: tokens.colorNeutralForeground2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '30vh',
overflowY: 'auto',
},
loadingEllipsis: {
fontSize: tokens.fontSizeBase500,
animationName: {
Expand Down
46 changes: 5 additions & 41 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,11 @@ import MarkdownContent from '@/components/Markdown/MarkdownContent'
import MediaPreview from '@/components/Media/MediaPreview'

import type { DisplayScore, Message, MessageAttachment } from '@/types'
import { scoreSummaryFromBackendScore, scoreSummaryValue } from '@/utils/scoreSummary'

import { useMessageListStyles } from './MessageList.styles'
import { formatStructuredMessage, getRenderMessagePieces } from './messageViewModel'
import ScoreDetails from './ScoreDetails'

interface MessageListProps {
onEditConversation?: () => void
Expand Down Expand Up @@ -62,51 +64,13 @@ interface MessageListProps {
}

function scoreDisplayValue(score: DisplayScore): string {
// An undetermined score has no value, so name its status instead.
return score.score_value ?? score.status ?? ''
return scoreSummaryValue(scoreSummaryFromBackendScore(score))
}

function scoreDisplayLabel(score: DisplayScore): string {
return `${score.is_objective_score ? 'Final score' : 'Score'}: ${scoreDisplayValue(score)}`
}

function ScoreDetails({ score, testId }: { score: DisplayScore; testId: string }) {
const styles = useMessageListStyles()
const categories = score.score_category?.filter(Boolean) ?? []

return (
<div className={styles.scoreSurface} data-testid={testId}>
<Text weight="semibold">Score details</Text>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Score</Text>
<Badge appearance="tint" color="brand" size="small" className={styles.scoreValue}>{scoreDisplayValue(score)}</Badge>
</div>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Scorer</Text>
<Text size={200} className={styles.scoreValue}>{score.scorer_type}</Text>
</div>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Result role</Text>
<Text size={200} className={styles.scoreValue}>
{score.is_objective_score ? 'Final score' : 'Supporting score'}
</Text>
</div>
{categories.length > 0 && (
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Category</Text>
<Text size={200} className={styles.scoreValue}>{categories.join(', ')}</Text>
</div>
)}
{score.score_rationale && (
<div className={styles.scoreRationale}>
<Text size={200} weight="semibold">Rationale</Text>
<Text size={200} className={styles.scoreRationaleText}>{score.score_rationale}</Text>
</div>
)}
</div>
)
}

function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; groupId: string | number; scoreIndex: number }) {
const styles = useMessageListStyles()
const displayValue = scoreDisplayValue(score)
Expand All @@ -130,7 +94,7 @@ function MessageScore({ score, groupId, scoreIndex }: { score: DisplayScore; gro
</PopoverTrigger>
</Tooltip>
<PopoverSurface>
<ScoreDetails score={score} testId={`message-score-details-${groupId}-${scoreIndex}`} />
<ScoreDetails score={scoreSummaryFromBackendScore(score)} testId={`message-score-details-${groupId}-${scoreIndex}`} />
</PopoverSurface>
</Popover>
)
Expand Down Expand Up @@ -429,7 +393,7 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s
id={`message-score-panel-${groupId}`}
aria-labelledby={`message-score-tab-${groupId}-${selectedIndex}`}
>
<ScoreDetails score={selectedScore} testId={`message-score-details-${groupId}-${selectedIndex}`} />
<ScoreDetails score={scoreSummaryFromBackendScore(selectedScore)} testId={`message-score-details-${groupId}-${selectedIndex}`} />
</div>
</PopoverSurface>
</Popover>
Expand Down
48 changes: 48 additions & 0 deletions frontend/src/components/Chat/ScoreDetails.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useScoreDetailsStyles = makeStyles({
surface: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXS,
width: 'min(360px, calc(100vw - 64px))',
minWidth: 0,
maxWidth: '100%',
},
row: {
display: 'flex',
alignItems: 'flex-start',
columnGap: tokens.spacingHorizontalS,
width: '100%',
minWidth: 0,
},
label: {
flex: '0 0 72px',
color: tokens.colorNeutralForeground2,
},
value: {
display: 'block',
flex: '0 1 auto',
minWidth: 0,
maxWidth: '100%',
height: 'auto',
whiteSpace: 'normal',
overflowWrap: 'anywhere',
wordBreak: 'break-word',
},
rationale: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXXS,
marginTop: tokens.spacingVerticalXS,
paddingTop: tokens.spacingVerticalXS,
borderTop: `1px solid ${tokens.colorNeutralStroke2}`,
},
rationaleText: {
color: tokens.colorNeutralForeground2,
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '30vh',
overflowY: 'auto',
},
})
36 changes: 36 additions & 0 deletions frontend/src/components/Chat/ScoreDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { ReactNode } from 'react'

import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { render, screen } from '@testing-library/react'

import ScoreDetails from './ScoreDetails'

function TestWrapper({ children }: { readonly children: ReactNode }) {
return <FluentProvider theme={webLightTheme}>{children}</FluentProvider>
}

describe('ScoreDetails', () => {
it('should show the value, scorer, role, categories, and rationale', () => {
render(<TestWrapper><ScoreDetails testId="details" score={{
scorer: 'SelfAskRefusalScorer', scoreType: 'true_false', value: 'true', status: 'complete',
rationale: 'The target complied.', categories: ['jailbreak', '', 'harm'], isObjective: true,
}} /></TestWrapper>)

const details = screen.getByTestId('details')
expect(details).toHaveTextContent('Score details')
expect(screen.getByText('true')).toBeInTheDocument()
expect(screen.getByText('SelfAskRefusalScorer')).toBeInTheDocument()
expect(screen.getByText('Final score')).toBeInTheDocument()
expect(screen.getByText('jailbreak, harm')).toBeInTheDocument()
expect(screen.getByText('The target complied.')).toBeInTheDocument()
})

it('should name an undetermined score by its status and omit empty sections', () => {
render(<TestWrapper><ScoreDetails score={{ scorer: 'Judge', scoreType: 'float_scale', status: 'undetermined' }} /></TestWrapper>)

expect(screen.getByText('undetermined')).toBeInTheDocument()
expect(screen.getByText('Supporting score')).toBeInTheDocument()
expect(screen.queryByText('Category')).not.toBeInTheDocument()
expect(screen.queryByText('Rationale')).not.toBeInTheDocument()
})
})
49 changes: 49 additions & 0 deletions frontend/src/components/Chat/ScoreDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { Badge, Text } from '@fluentui/react-components'

import type { ScoreSummary } from '@/types'
import { scoreSummaryValue } from '@/utils/scoreSummary'

import { useScoreDetailsStyles } from './ScoreDetails.styles'

interface ScoreDetailsProps {
readonly score: ScoreSummary
readonly testId?: string
}

/** One score's value, scorer, role, categories, and rationale, as shown in a score popover. */
export default function ScoreDetails({ score, testId }: ScoreDetailsProps) {
const styles = useScoreDetailsStyles()
const categories = score.categories?.filter(Boolean) ?? []

return (
<div className={styles.surface} data-testid={testId}>
<Text weight="semibold">Score details</Text>
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.label}>Score</Text>
<Badge appearance="tint" color="brand" size="small" className={styles.value}>{scoreSummaryValue(score)}</Badge>
</div>
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.label}>Scorer</Text>
<Text size={200} className={styles.value}>{score.scorer}</Text>
</div>
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.label}>Result role</Text>
<Text size={200} className={styles.value}>
{score.isObjective ? 'Final score' : 'Supporting score'}
</Text>
</div>
{categories.length > 0 && (
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.label}>Category</Text>
<Text size={200} className={styles.value}>{categories.join(', ')}</Text>
</div>
)}
{score.rationale && (
<div className={styles.rationale}>
<Text size={200} weight="semibold">Rationale</Text>
<Text size={200} className={styles.rationaleText}>{score.rationale}</Text>
</div>
)}
</div>
)
}
15 changes: 3 additions & 12 deletions frontend/src/components/Home/Home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { useEffect, useState } from 'react'
import type { ReactNode } from 'react'
import { useNavigate } from 'react-router'
import {
Badge,
Button,
MessageBar,
MessageBarBody,
Expand All @@ -11,7 +10,6 @@ import {
TabList,
Text,
} from '@fluentui/react-components'
import type { BadgeProps } from '@fluentui/react-components'
import {
AddRegular,
ArrowRightRegular,
Expand All @@ -24,26 +22,19 @@ import {
} from '@fluentui/react-icons'

import OutcomeBadge from '@/components/OutcomeBadge'
import ScanStatusBadge from '@/components/ScanStatusBadge'
import type { ViewName } from '@/components/Sidebar/Navigation'
import { useRuntime } from '@/hooks/useRuntime'
import { useAutoRefresh } from '@/hooks/useAutoRefresh'
import { attacksApi, operationsApi, scenariosApi } from '@/services/api'
import { toApiError } from '@/services/errors'
import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem, ScenarioRunState } from '@/types'
import type { AttackSummary, Operation, OperationListItem, ScenarioRunListItem } from '@/types'
import { scenarioRunRoutePath } from '@/utils/routeParams'

import { useHomeStyles } from './Home.styles'

const RECENT_WORK_LIMIT = 5
const VISIBLE_OPERATIONS_LIMIT = 3
const SCAN_STATUS_LABELS: Record<ScenarioRunState, string> = {
CREATED: 'Created', QUEUED: 'Queued', IN_PROGRESS: 'Executing',
COMPLETED: 'Completed', FAILED: 'Failed', CANCELLED: 'Cancelled',
}
const SCAN_STATUS_COLORS: Record<ScenarioRunState, BadgeProps['color']> = {
CREATED: 'informative', QUEUED: 'informative', IN_PROGRESS: 'brand',
COMPLETED: 'success', FAILED: 'danger', CANCELLED: 'warning',
}

interface HomeProps {
readonly selectedOperation: Operation | null
Expand Down Expand Up @@ -165,7 +156,7 @@ export default function Home({
title: scan.scenario_registry_name || scan.scenario_name,
operationName: scan.operation_name,
updatedAt: scan.updated_at,
badge: <Badge appearance="tint" color={SCAN_STATUS_COLORS[scan.status]}>{SCAN_STATUS_LABELS[scan.status]}</Badge>,
badge: <ScanStatusBadge status={scan.status} />,
open: () => { void navigate(scenarioRunRoutePath(scan.scenario_result_id)) },
})),
].filter((item: RecentItem) => filter === 'all' || (filter === 'attacks' ? item.kind === 'Attack' : item.kind === 'Scan'))
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/components/OutcomeBadge.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,17 @@ describe('OutcomeBadge', () => {
expect(screen.getByTestId('verdict')).toHaveTextContent('No')
expect(screen.getByTestId('verdict')).toHaveClass('custom')
})

it.each<[AttackOutcome, 'outcome' | 'success', string]>([
['success', 'success', 'True'],
['failure', 'success', 'False'],
['error', 'success', 'Error'],
['success', 'outcome', 'Success'],
['undetermined', 'outcome', 'Undetermined'],
])('labels %s in %s style as %s', (outcome: AttackOutcome, labelStyle: 'outcome' | 'success', label: string) => {
render(<TestWrapper><OutcomeBadge outcome={outcome} labelStyle={labelStyle} testId="verdict" /></TestWrapper>)
expect(screen.getByTestId('verdict')).toHaveTextContent(label)
})
})

describe('OutcomeIcon', () => {
Expand Down
17 changes: 16 additions & 1 deletion frontend/src/components/OutcomeBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
QuestionCircleRegular,
} from '@fluentui/react-icons'

import { formatAttackOutcome, formatAttackSuccess } from '@/components/AttackResults/attackAttemptFormatting'
import type { AttackOutcome } from '@/types'

import { useOutcomeBadgeStyles } from './OutcomeBadge.styles'
Expand All @@ -20,13 +21,26 @@ const OUTCOME_ICONS: Record<AttackOutcome, ReactElement> = {
undetermined: <QuestionCircleRegular />,
}

/**
* How a saved outcome is worded: `outcome` capitalizes it ("Success"), `success`
* answers "Attack success?" ("True"/"False"). Omitted keeps the raw outcome word.
*/
type OutcomeLabelStyle = 'outcome' | 'success'

function outcomeLabel(outcome: AttackOutcome, labelStyle: OutcomeLabelStyle | undefined): string {
if (labelStyle === 'success') return formatAttackSuccess(outcome)
if (labelStyle === 'outcome') return formatAttackOutcome(outcome)
return outcome
}

interface OutcomeBadgeProps {
readonly outcome?: AttackOutcome | null
readonly testId?: string
readonly className?: string
readonly appearance?: BadgeProps['appearance']
readonly size?: BadgeProps['size']
readonly label?: string
readonly labelStyle?: OutcomeLabelStyle
}

interface OutcomeIconProps {
Expand All @@ -50,6 +64,7 @@ export default function OutcomeBadge({
appearance = 'filled',
size,
label,
labelStyle,
}: OutcomeBadgeProps) {
const styles = useOutcomeBadgeStyles()
const normalizedOutcome = outcome ?? 'undetermined'
Expand All @@ -62,7 +77,7 @@ export default function OutcomeBadge({
className={mergeClasses(styles[normalizedOutcome], styles[appearance], className)}
data-testid={testId}
>
{label ?? normalizedOutcome}
{label ?? outcomeLabel(normalizedOutcome, labelStyle)}
</Badge>
)
}
Loading
Loading