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
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { objectivePreview } from './attackAttemptFormatting'
import { formatAttemptTiming, formatRetries, formatTimestamp, objectivePreview } from './attackAttemptFormatting'

const LONE_SURROGATE_REGEX = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/

Expand Down Expand Up @@ -45,3 +45,21 @@ describe('objectivePreview', () => {
expect(objectivePreview('', 'seed-456')).toBe('Objective unavailable (seed-456)')
})
})

describe('formatRetries', () => {
it('says when there were no retries and pluralizes the rest', () => {
expect(formatRetries(0)).toBe('No retries')
expect(formatRetries(1)).toBe('1 retry')
expect(formatRetries(3)).toBe('3 retries')
})
})

describe('formatAttemptTiming', () => {
it('joins duration, retries, and timestamp on one line', () => {
const timestamp = '2026-01-01T00:04:30Z'
expect(formatAttemptTiming({ execution_time_ms: 270_000, total_retries: 0, timestamp }))
.toBe(`4m 30s · No retries · ${formatTimestamp(timestamp)}`)
expect(formatAttemptTiming({ execution_time_ms: 5_000, total_retries: 2, timestamp }))
.toBe(`5s · 2 retries · ${formatTimestamp(timestamp)}`)
})
})
16 changes: 16 additions & 0 deletions frontend/src/components/AttackResults/attackAttemptFormatting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,22 @@ export function formatDuration(milliseconds: number): string {
return `${seconds}s`
}

export function formatRetries(retries: number): string {
if (retries === 0) return 'No retries'
return `${retries} ${retries === 1 ? 'retry' : 'retries'}`
}

/** One line of attempt timing, such as "4m 30s · No retries · Jan 1, 2026, 12:00:05 AM". */
export function formatAttemptTiming(
attempt: Pick<ScenarioProgressResult, 'execution_time_ms' | 'total_retries' | 'timestamp'>,
): string {
return [
formatDuration(attempt.execution_time_ms),
formatRetries(attempt.total_retries),
formatTimestamp(attempt.timestamp),
].join(' · ')
}

export function objectivePreview(objective: string | null, fallbackId: string): string {
if (!objective) {
return `Objective unavailable (${fallbackId})`
Expand Down
9 changes: 9 additions & 0 deletions frontend/src/components/Chat/AttachToFindingsButton.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { makeStyles } from '@fluentui/react-components'

import { mobileTouchTargetHeight } from '@/styles/touchTargets'

export const useAttachToFindingsButtonStyles = makeStyles({
button: {
...mobileTouchTargetHeight,
},
})
75 changes: 75 additions & 0 deletions frontend/src/components/Chat/AttachToFindingsButton.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { MemoryRouter } from 'react-router'

import { attacksApi, operationsApi } from '@/services/api'

import AttachToFindingsButton from './AttachToFindingsButton'

jest.mock('@/services/api', () => ({
attacksApi: { getAttack: jest.fn() },
operationsApi: {
get: jest.fn(), list: jest.fn(), listFindings: jest.fn(), attachFindingEvidence: jest.fn(), createFinding: jest.fn(),
getFindingOptions: jest.fn(),
},
}))

const OPERATION = { id: 'scan-operation', name: 'Scan operation', created_at: '2026-10-08T12:00:00Z' }
const FINDING = {
id: 'finding', operation_id: OPERATION.id, title: 'Proof', severity: 'low', description: '',
created_at: '2026-10-08T12:00:00Z', evidence_count: 0,
}

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

describe('AttachToFindingsButton', () => {
beforeEach(() => {
jest.clearAllMocks()
jest.mocked(attacksApi.getAttack).mockResolvedValue({ operation_id: null } as never)
jest.mocked(operationsApi.get).mockResolvedValue(OPERATION as never)
jest.mocked(operationsApi.list).mockResolvedValue({ items: [{ ...OPERATION, finding_counts: {} }] } as never)
jest.mocked(operationsApi.listFindings).mockResolvedValue({ items: [FINDING], has_more: false, next_offset: null } as never)
jest.mocked(operationsApi.attachFindingEvidence).mockResolvedValue({ item: { id: 'evidence' }, created: true } as never)
})

it('should attach the attack to a finding in the default operation and report it', async () => {
const user = userEvent.setup()
const onAttached = jest.fn()
render(<TestWrapper>
<AttachToFindingsButton attackResultId="attack-1" defaultOperationId={OPERATION.id} onAttached={onAttached} />
</TestWrapper>)

const button = screen.getByRole('button', { name: 'Attach to findings' })
await user.click(button)
expect(await screen.findByRole('dialog')).toBeInTheDocument()
expect(operationsApi.get).toHaveBeenCalledWith(OPERATION.id)
await user.click(await screen.findByRole('radio', { name: /Proof/ }))
await user.click(screen.getByRole('button', { name: 'Attach attack' }))

await waitFor(() => { expect(onAttached).toHaveBeenCalledTimes(1) })
expect(operationsApi.attachFindingEvidence).toHaveBeenCalledWith(OPERATION.id, FINDING.id, { attack_result_id: 'attack-1' })
await waitFor(() => { expect(button).toHaveFocus() })
})

it('should not report an attachment when the dialog is cancelled', async () => {
const user = userEvent.setup()
const onAttached = jest.fn()
render(<TestWrapper><AttachToFindingsButton attackResultId="attack-1" onAttached={onAttached} /></TestWrapper>)

await user.click(screen.getByRole('button', { name: 'Attach to findings' }))
await user.click(await screen.findByRole('button', { name: 'Cancel' }))

await waitFor(() => { expect(screen.queryByRole('dialog')).not.toBeInTheDocument() })
expect(onAttached).not.toHaveBeenCalled()
})

it('should explain why a running attack cannot be attached yet', () => {
render(<TestWrapper><AttachToFindingsButton attackResultId="attack-1" disabled /></TestWrapper>)
const button = screen.getByRole('button', { name: 'Attach to findings' })
expect(button).toBeDisabled()
expect(button).toHaveAttribute('title', 'Available after the attack finishes')
})
})
37 changes: 37 additions & 0 deletions frontend/src/components/Chat/AttachToFindingsButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { useRef, useState } from 'react'

import { Button } from '@fluentui/react-components'
import { AttachRegular } from '@fluentui/react-icons'

import { useAttachToFindingsButtonStyles } from './AttachToFindingsButton.styles'
import FindingEvidenceDialog from './FindingEvidenceDialog'

interface AttachToFindingsButtonProps {
readonly attackResultId: string
/** The Operation offered first when the attack has no Operation of its own. */
readonly defaultOperationId?: string | null
/** Running attacks cannot be attached until they are saved. */
readonly disabled?: boolean
/** Called after the attack is attached, such as to refresh `useAttackFindings`. */
readonly onAttached?: () => void
}

/** Opens the finding picker to attach one attack as evidence, returning focus to the button. */
export default function AttachToFindingsButton({
attackResultId, defaultOperationId = null, disabled = false, onAttached,
}: AttachToFindingsButtonProps) {
Comment on lines +19 to +22

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] Part-3 building blocks have no caller in this PR

Outside tests, nothing in this PR renders AttachToFindingsButton. The same goes for OperationParentLink, DetailFacts, AttackActivity, useAttackFindings, the new helpers in attackAttemptFormatting.ts (lines 79-93), and the new onAttached prop on FindingEvidenceDialog, which the live dialog in ChatWindow doesn't pass. The PR description itself says the new blocks are unused until layer 3. Since each PR merges into main on its own, please drop them here and add them in #4032 with their callers.

const styles = useAttachToFindingsButtonStyles()
const [open, setOpen] = useState(false)
const trigger = useRef<HTMLButtonElement>(null)
return (
<>
<Button ref={trigger} className={styles.button} icon={<AttachRegular />} disabled={disabled}
title={disabled ? 'Available after the attack finishes' : 'Attach this attack as evidence to a finding'}
onClick={() => { setOpen(true) }}>
Attach to findings
</Button>
<FindingEvidenceDialog attackResultId={attackResultId} disabled={disabled} defaultOperationId={defaultOperationId}
open={open} onOpenChange={setOpen} returnFocusRef={trigger} onAttached={onAttached} />
</>
)
}
6 changes: 5 additions & 1 deletion frontend/src/components/Chat/FindingEvidenceDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ interface FindingEvidenceDialogProps {
onOpenChange: (open: boolean) => void
/** Receives focus when the dialog closes, such as the control that opened it. */
returnFocusRef?: React.RefObject<HTMLElement | null>
/** Called after the attack is linked to a finding, new or existing. */
onAttached?: () => void
}

interface AttachmentSource {
Expand All @@ -36,7 +38,7 @@ interface AttachmentSource {
}

export default function FindingEvidenceDialog({
attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef,
attackResultId, disabled, defaultOperationId = null, open, onOpenChange, returnFocusRef, onAttached,
}: FindingEvidenceDialogProps) {
const styles = useFindingEvidenceDialogStyles()
const viewKey = attackResultId
Expand Down Expand Up @@ -144,6 +146,7 @@ export default function FindingEvidenceDialog({
setCreating(false)
onOpenChange(false)
setNotice({ key: viewKey, text: `Finding created and attack attached: ${finding.title}` })
onAttached?.()
}

const attach = async (): Promise<void> => {
Expand All @@ -158,6 +161,7 @@ export default function FindingEvidenceDialog({
})
setNotice({ key: viewKey, text: response.created ? 'Attached' : 'Already attached' })
onOpenChange(false)
onAttached?.()
} catch (cause: unknown) {
setAttachError(toApiError(cause).detail)
} finally {
Expand Down
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 @@ -235,50 +235,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 All @@ -60,51 +62,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 @@ -128,7 +92,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 @@ -427,7 +391,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',
},
})
Loading
Loading