Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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, targetModelName, targetType } from './utils/targetIdentity'
import { attacksApi, versionApi } from './services/api'
import { toApiError } from './services/errors'
Expand Down Expand Up @@ -51,6 +51,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 @@ -201,6 +204,9 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
outcome: null,
lastScore: null,
})
attacksApi
.getAttack(routeAttackId)
Expand All @@ -212,6 +218,9 @@ function App() {
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective,
Comment thread
jbolor21 marked this conversation as resolved.
Outdated
outcome: attack.outcome ?? null,
lastScore: attack.last_score ?? null,
status: 'success',
})
})
Expand All @@ -228,6 +237,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 @@ -294,6 +306,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 @@ -333,6 +348,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',
},
})
62 changes: 62 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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 no score', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={null} />
</TestWrapper>
)

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

it('shows only the score value on the chip, labeled "Score"', () => {
render(
<TestWrapper>
<AttackVerdictChip outcome="failure" score={sampleScore} />
</TestWrapper>
)

const chip = screen.getByRole('button', { name: /score true/i })
expect(within(chip).getByText('Score')).toBeInTheDocument()
expect(within(chip).getByText('true')).toBeInTheDocument()
// The outcome word must never appear on the chip itself.
expect(within(chip).queryByText(/failure/i)).not.toBeInTheDocument()
})

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

await user.click(screen.getByRole('button', { name: /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()
})
})
86 changes: 86 additions & 0 deletions frontend/src/components/Chat/AttackVerdictChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import {
Button,
Text,
Badge,
Popover,
PopoverTrigger,
PopoverSurface,
} from '@fluentui/react-components'
import type { AttackOutcome, ScoreView } from '../../types'
import { 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()

// Only the score value is surfaced in the ribbon, so there's nothing to show without a score.
if (!score) return null
Comment thread
jbolor21 marked this conversation as resolved.
Outdated

const resolvedOutcome = resolveOutcome(outcome)
const categories = score.score_category?.filter(Boolean) ?? []
const scoreColor = getScoreColor(score.score_type, score.score_value)
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={`Score ${score.score_value}`}
>
<span className={styles.chipLabel}>Score</span>
<Badge appearance="filled" size="medium" style={scoreBadgeStyle}>
{score.score_value}
</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}>Score</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>
<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>
)
}
15 changes: 14 additions & 1 deletion frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,16 @@ import ChatInputArea from './ChatInputArea'
import ConversationPanel from './ConversationPanel'
import ConverterPanel from './ConverterPanel'
import TargetBadge from './TargetBadge'
import ObjectiveHeader from './ObjectiveHeader'
import AttackVerdictChip from './AttackVerdictChip'
import type { PieceConversion } from './converterTypes'
import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes'
import LabelsBar from '../Labels/LabelsBar'
import type { ChatInputAreaHandle } from './ChatInputArea'
import { attacksApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper'
import type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types'
import type { Message, MessageAttachment, TargetInstance, TargetInfo, AttackOutcome, ScoreView } from '../../types'
import { targetEndpoint, targetModelName, targetType } from '../../utils/targetIdentity'
import type { ViewName } from '../Sidebar/Navigation'
import { useChatWindowStyles } from './ChatWindow.styles'
Expand All @@ -42,6 +44,12 @@ interface ChatWindowProps {
isLoadingAttack?: boolean
/** Number of related (non-main) conversations in the loaded attack. */
relatedConversationCount?: number
/** The loaded attack's objective (empty for new/manual attacks). */
objective?: string
/** The loaded attack's outcome verdict. */
outcome?: AttackOutcome | null
/** The loaded attack's final score, if any. */
lastScore?: ScoreView | null
}

export default function ChatWindow({
Expand All @@ -59,6 +67,9 @@ export default function ChatWindow({
attackTarget,
isLoadingAttack,
relatedConversationCount,
objective = '',
outcome,
lastScore,
}: ChatWindowProps) {
const styles = useChatWindowStyles()
const [messages, setMessages] = useState<Message[]>([])
Expand Down Expand Up @@ -610,6 +621,7 @@ export default function ChatWindow({
)}
</div>
<div className={styles.ribbonActions}>
<AttackVerdictChip outcome={outcome} score={lastScore} />
Comment thread
jbolor21 marked this conversation as resolved.
Outdated
<Tooltip content="Toggle conversations panel" relationship="label">
<Button
appearance="subtle"
Expand All @@ -635,6 +647,7 @@ export default function ChatWindow({
</Tooltip>
</div>
</div>
<ObjectiveHeader objective={objective} />
{systemMessage && <SystemPromptBanner content={systemMessage.content} />}
<MessageList
messages={messages}
Expand Down
41 changes: 41 additions & 0 deletions frontend/src/components/Chat/ObjectiveHeader.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useObjectiveHeaderStyles = makeStyles({
root: {
flexShrink: 0,
display: 'flex',
flexDirection: 'row',
alignItems: 'baseline',
columnGap: tokens.spacingHorizontalS,
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
backgroundColor: tokens.colorNeutralBackground2,
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
borderLeft: `3px solid ${tokens.colorBrandStroke1}`,
},
label: {
flexShrink: 0,
},
content: {
flexGrow: 1,
minWidth: 0,
color: tokens.colorNeutralForeground1,
fontSize: tokens.fontSizeBase300,
},
contentCollapsed: {
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
},
contentExpanded: {
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
maxHeight: '30vh',
overflowY: 'auto',
},
toggle: {
flexShrink: 0,
minWidth: 'auto',
whiteSpace: 'nowrap',
color: tokens.colorBrandForeground1,
},
})
Loading
Loading