Skip to content
20 changes: 19 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnec
import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults'
import { filtersFromSearchParams, filtersToSearchParams } from './components/History/historyFilters'
import type { ViewName } from './components/Sidebar/Navigation'
import type { TargetInstance, TargetInfo } from './types'
import type { TargetInstance, TargetInfo, AttackOutcome, ScoreView } from './types'
import {
targetEndpoint,
targetIdentifierHash,
Expand Down Expand Up @@ -56,6 +56,9 @@ interface LoadedAttack {
labels: Record<string, string> | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
outcome: AttackOutcome | null
lastScore: ScoreView | null
status: AttackLoadStatus
}

Expand Down Expand Up @@ -206,6 +209,9 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
})
attacksApi
.getAttack(routeAttackId)
Expand All @@ -217,6 +223,9 @@ function App() {
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
outcome: attack.outcome ?? null,
lastScore: attack.last_score ?? null,
status: 'success',
})
})
Expand All @@ -233,6 +242,9 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
})
})
// Drop a stale response once the route has moved on to another attack.
Expand Down Expand Up @@ -300,6 +312,9 @@ function App() {
labels: null,
target,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
status: 'success',
})
// Replace when promoting an empty /chat to its attack url (first message);
Expand Down Expand Up @@ -339,6 +354,9 @@ function App() {
attackTarget={readyAttack ? readyAttack.target : null}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
objective={readyAttack ? readyAttack.objective : ''}
outcome={readyAttack ? readyAttack.outcome : null}
lastScore={readyAttack ? readyAttack.lastScore : null}
/>
)

Expand Down
42 changes: 42 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useAttackVerdictChipStyles = makeStyles({
chip: {
display: 'inline-flex',
alignItems: 'center',
columnGap: tokens.spacingHorizontalXS,
},
chipLabel: {
textTransform: 'capitalize',
},
surface: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXS,
minWidth: '240px',
maxWidth: '360px',
},
row: {
display: 'flex',
columnGap: tokens.spacingHorizontalS,
},
rowLabel: {
minWidth: '72px',
color: tokens.colorNeutralForeground2,
},
rationaleBlock: {
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',
},
})
114 changes: 114 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import AttackVerdictChip from './AttackVerdictChip'
import type { ScoreView } from '../../types'

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
)

const sampleScore: ScoreView = {
id: 'score-1',
scorer_type: 'SelfAskRefusalScorer',
score_type: 'true_false',
score_value: 'true',
score_category: ['refusal'],
score_rationale: 'The model refused the request.',
timestamp: '2026-01-15T11:00:00Z',
}

describe('AttackVerdictChip', () => {
it('renders nothing when there is neither an outcome nor a score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome={null} score={null} />
</TestWrapper>
)

expect(screen.queryByTestId('attack-verdict-chip')).not.toBeInTheDocument()
expect(screen.queryByTestId('attack-score-chip')).not.toBeInTheDocument()
})

it('renders an outcome-only badge when there is an outcome but no score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={null} />
</TestWrapper>
)

// No score means no interactive score chip/popover, just the outcome badge.
expect(screen.queryByTestId('attack-score-chip')).not.toBeInTheDocument()
const chip = screen.getByTestId('attack-verdict-chip')
expect(within(chip).getByText('failure')).toBeInTheDocument()
})

it('shows a single chip labeled with the outcome, tinted by the score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

const chip = screen.getByRole('button', { name: /verdict failure, score true/i })
// The chip shows the outcome; the raw score value lives in the popover
expect(within(chip).getByText('failure')).toBeInTheDocument()
expect(within(chip).queryByText('true')).not.toBeInTheDocument()
})

it('opens a popover with full verdict details when clicked', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score true/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).getByText('true_false')).toBeInTheDocument()
expect(within(details).getByText('SelfAskRefusalScorer')).toBeInTheDocument()
expect(within(details).getByText('refusal')).toBeInTheDocument()
expect(within(details).getByText('The model refused the request.')).toBeInTheDocument()
})

it('shows the underlying scale score when a thresholded verdict provides one', async () => {
const user = userEvent.setup()
const thresholdScore: ScoreView = {
id: 'score-2',
scorer_type: 'FloatScaleThresholdScorer',
score_type: 'true_false',
score_value: 'false',
score_category: ['humor'],
score_rationale: 'Normalized scale score: 0.5 < threshold 0.6',
scale_score: 0.5,
timestamp: '2026-01-15T11:00:00Z',
}
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={thresholdScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score false/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).getByText('Scale score')).toBeInTheDocument()
expect(within(details).getByText('0.50')).toBeInTheDocument()
})

it('omits the scale score row for a plain true/false score', async () => {
const user = userEvent.setup()
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

await user.click(screen.getByRole('button', { name: /verdict failure, score true/i }))

const details = await screen.findByTestId('attack-score-details')
expect(within(details).queryByText('Scale score')).not.toBeInTheDocument()
})
})
115 changes: 115 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import {
Button,
Text,
Badge,
Popover,
PopoverTrigger,
PopoverSurface,
} from '@fluentui/react-components'
import type { AttackOutcome, ScoreView } from '../../types'
import { OUTCOME_COLORS, OUTCOME_ICONS, resolveOutcome } from '../../utils/attackOutcome'
import { getScoreColor } from '../../utils/scoreColor'
import { useAttackVerdictChipStyles } from './AttackVerdictChip.styles'

interface AttackVerdictChipProps {
outcome?: AttackOutcome | null
score?: ScoreView | null
}

export default function AttackVerdictChip({ outcome, score }: AttackVerdictChipProps) {
const styles = useAttackVerdictChipStyles()

// The verdict is the attack's outcome plus its score. If both are missing, there's nothing to show
// (e.g. a manual GUI attack, which can't be scored yet), so the chip is omitted entirely.
if (!outcome && !score) return null
const resolvedOutcome = resolveOutcome(outcome)
const outcomeBadge = (
<Badge
appearance="filled"
color={OUTCOME_COLORS[resolvedOutcome]}
icon={OUTCOME_ICONS[resolvedOutcome]}
data-testid="attack-outcome-badge"
>
{resolvedOutcome}
</Badge>
)

// Outcome-only verdict: strategies that produce an outcome but no score (e.g. sequential,
// barge-in) still get the outcome badge, matching the history table, with no score popover.
if (!score) {
return (
<div className={styles.chip} data-testid="attack-verdict-chip">
{outcomeBadge}
</div>
)
}

const categories = score.score_category?.filter(Boolean) ?? []
// A FloatScaleThresholdScorer keeps the raw scale score behind the true/false
// score; surface the float value and let it determine the badge tone.
const scaleScore = score.scale_score ?? null
const scoreColor = getScoreColor(resolvedOutcome, score.score_type, score.score_value, scaleScore)
const scoreBadgeStyle = {
backgroundColor: scoreColor.background,
borderColor: scoreColor.background,
color: scoreColor.foreground,
}

return (
<Popover withArrow>
<PopoverTrigger disableButtonEnhancement>
<Button
appearance="subtle"
className={styles.chip}
data-testid="attack-score-chip"
aria-label={`Verdict ${resolvedOutcome}, score ${score.score_value}`}
>
<Badge appearance="filled" size="medium" style={scoreBadgeStyle}>
{resolvedOutcome}
</Badge>
Comment thread
jbolor21 marked this conversation as resolved.
</Button>
</PopoverTrigger>
<PopoverSurface>
<div className={styles.surface} data-testid="attack-score-details">
<Text weight="semibold" size={300}>Verdict</Text>
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Value</Text>
<Badge appearance="filled" size="small" style={scoreBadgeStyle}>
{score.score_value}
</Badge>
</div>
{scaleScore !== null && (
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Scale score</Text>
<Text size={200}>{scaleScore.toFixed(2)}</Text>
</div>
)}
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Type</Text>
<Text size={200}>{score.score_type}</Text>
</div>
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Scorer</Text>
<Text size={200}>{score.scorer_type}</Text>
</div>
{categories.length > 0 && (
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Category</Text>
<Text size={200}>{categories.join(', ')}</Text>
</div>
)}
<div className={styles.row}>
<Text size={200} weight="semibold" className={styles.rowLabel}>Outcome</Text>
<Text size={200} className={styles.chipLabel}>{resolvedOutcome}</Text>
</div>
{score.score_rationale && (
<div className={styles.rationaleBlock}>
<Text size={200} weight="semibold">Rationale</Text>
<Text size={200} className={styles.rationaleText}>{score.score_rationale}</Text>
</div>
)}
</div>
</PopoverSurface>
</Popover>
)
}
Loading