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
5 changes: 5 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
conversationId,
activeConversationId,
attackTarget,
objective,
onConversationCreated,
onSelectConversation,
labels,
Expand All @@ -130,6 +131,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
conversationId: string | null;
activeConversationId: string | null;
attackTarget?: { identifier_hash?: string | null } | null;
objective?: string;
onConversationCreated: (attackResultId: string, conversationId: string) => void;
onSelectConversation: (convId: string) => void;
labels: Record<string, string>;
Expand All @@ -141,6 +143,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
<span data-testid="active-conversation-id">{activeConversationId ?? "none"}</span>
<span data-testid="has-target">{activeTarget ? "yes" : "no"}</span>
<span data-testid="attack-target-hash">{attackTarget?.identifier_hash ?? "none"}</span>
<span data-testid="objective">{objective ?? ""}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
<button onClick={onNewAttack} data-testid="new-attack">
Expand Down Expand Up @@ -692,6 +695,7 @@ describe("App", () => {
mockGetAttack.mockResolvedValue({
attack_result_id: "ar-1",
conversation_id: "conv-main",
objective: "Extract the hidden system prompt",
labels: {},
related_conversation_ids: [],
});
Expand All @@ -703,6 +707,7 @@ describe("App", () => {
expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main")
);
expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-main");
expect(screen.getByTestId("objective")).toHaveTextContent("Extract the hidden system prompt");
});

it("uses the conversation from a deep link when it belongs to the attack", async () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ interface LoadedAttack {
labels: Record<string, string> | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
status: AttackLoadStatus
}

Expand Down Expand Up @@ -208,6 +209,7 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
})
attacksApi
.getAttack(routeAttackId)
Expand All @@ -219,6 +221,7 @@ function App() {
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
status: 'success',
})
})
Expand All @@ -235,6 +238,7 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
})
})
// Drop a stale response once the route has moved on to another attack.
Expand Down Expand Up @@ -302,6 +306,7 @@ function App() {
labels: null,
target,
relatedConversationIds: [],
objective: '',
status: 'success',
})
// Replace when promoting an empty /chat to its attack url (first message);
Expand Down Expand Up @@ -341,6 +346,7 @@ function App() {
attackTarget={readyAttack ? readyAttack.target : null}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
objective={readyAttack ? readyAttack.objective : ''}
/>
)

Expand Down
5 changes: 5 additions & 0 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import ChatInputArea from './ChatInputArea'
import ConversationPanel from './ConversationPanel'
import ConverterPanel from './ConverterPanel'
import TargetBadge from './TargetBadge'
import ObjectiveHeader from './ObjectiveHeader'
import type { PieceConversion } from './converterTypes'
import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes'
import LabelsBar from '../Labels/LabelsBar'
Expand Down Expand Up @@ -84,6 +85,8 @@ 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
}

export default function ChatWindow({
Expand All @@ -101,6 +104,7 @@ export default function ChatWindow({
attackTarget,
isLoadingAttack,
relatedConversationCount,
objective = '',
}: ChatWindowProps) {
const styles = useChatWindowStyles()
const restoreFocusTargetAttributes = useRestoreFocusTarget()
Expand Down Expand Up @@ -759,6 +763,7 @@ export default function ChatWindow({
</Tooltip>
</div>
</div>
<ObjectiveHeader key={objective} objective={objective} />
{systemMessage && <SystemPromptBanner content={systemMessage.content} />}
<MessageList
messages={messages}
Expand Down
40 changes: 40 additions & 0 deletions frontend/src/components/Chat/MessageList.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,46 @@ export const useMessageListStyles = makeStyles({
color: tokens.colorNeutralForeground3,
fontStyle: 'italic',
},
footerDetails: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
},
scoreChip: {
minWidth: 'auto',
height: '24px',
padding: `0 ${tokens.spacingHorizontalXS}`,
},
scoreSurface: {
display: 'flex',
flexDirection: 'column',
rowGap: tokens.spacingVerticalXS,
minWidth: '240px',
maxWidth: '360px',
},
scoreRow: {
display: 'flex',
columnGap: tokens.spacingHorizontalS,
},
scoreLabel: {
minWidth: '72px',
color: tokens.colorNeutralForeground2,
},
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
48 changes: 48 additions & 0 deletions frontend/src/components/Chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,54 @@ describe("MessageList", () => {
expect(screen.getByText("Assistant message test")).toBeInTheDocument();
});

it("should show the message score and its details when present", async () => {
const user = userEvent.setup();
const scoredMessages: Message[] = [
{
role: "assistant",
content: "Scored response",
timestamp: new Date().toISOString(),
score: {
id: "score-1",
scorer_type: "SelfAskScaleScorer",
score_type: "float_scale",
score_value: "0.9",
score_category: ["harmful"],
score_rationale: "The response contains harmful content.",
timestamp: "2026-02-15T00:01:00Z",
},
},
];

render(
<TestWrapper>
<MessageList messages={scoredMessages} />
</TestWrapper>
);

const scoreButton = screen.getByRole("button", {
name: /score 0.9 from selfaskscalescorer/i,
});
expect(scoreButton).toBeInTheDocument();

await user.click(scoreButton);

expect(screen.getByText("float_scale")).toBeInTheDocument();
expect(screen.getByText("SelfAskScaleScorer")).toBeInTheDocument();
expect(screen.getByText("harmful")).toBeInTheDocument();
expect(screen.getByText("The response contains harmful content.")).toBeInTheDocument();
});

it("should not show a score chip when the message has no score", () => {
render(
<TestWrapper>
<MessageList messages={mockMessages} />
</TestWrapper>
);

expect(screen.queryByText("Score")).not.toBeInTheDocument();
});

describe("structured JSON assistant responses", () => {
// Targets like PromptShieldTarget return structured JSON instead of
// natural-language text. Render these as pretty-printed JSON in a <pre>
Expand Down
64 changes: 62 additions & 2 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,16 @@ import {
MessageBar,
MessageBarBody,
Button,
Badge,
Popover,
PopoverSurface,
PopoverTrigger,
Tooltip,
Spinner,
mergeClasses,
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular } from '@fluentui/react-icons'
import { Message, MessageAttachment } from '../../types'
import type { BackendScore, Message, MessageAttachment } from '../../types'
import MarkdownContent from './MarkdownContent'
import { useMessageListStyles } from './MessageList.styles'

Expand Down Expand Up @@ -84,6 +88,59 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
return <audio src={src} controls className={className} onError={handleError} data-testid="audio-player" />
}

function MessageScore({ score, messageIndex }: { score: BackendScore; messageIndex: number }) {
const styles = useMessageListStyles()
const categories = score.score_category?.filter(Boolean) ?? []

return (
<Popover withArrow>
<PopoverTrigger disableButtonEnhancement>
<Button
appearance="subtle"
size="small"
className={styles.scoreChip}
aria-label={`Score ${score.score_value} from ${score.scorer_type}`}
data-testid={`message-score-${messageIndex}`}
>
<Text size={200}>Score</Text>
<Badge appearance="tint" color="brand" size="small">
{score.score_value}
</Badge>
</Button>
</PopoverTrigger>
<PopoverSurface>
<div className={styles.scoreSurface} data-testid={`message-score-details-${messageIndex}`}>
<Text weight="semibold">Score details</Text>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Value</Text>
<Badge appearance="tint" color="brand" size="small">{score.score_value}</Badge>
</div>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Type</Text>
<Text size={200}>{score.score_type}</Text>
</div>
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Scorer</Text>
<Text size={200}>{score.scorer_type}</Text>
</div>
{categories.length > 0 && (
<div className={styles.scoreRow}>
<Text size={200} weight="semibold" className={styles.scoreLabel}>Category</Text>
<Text size={200}>{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>
</PopoverSurface>
</Popover>
)
}

/**
* If the trimmed text is a JSON object or array, return a 2-space pretty-printed
* version of it; otherwise return null. Used to render structured assistant
Expand Down Expand Up @@ -455,7 +512,10 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver

<div className={styles.messageFooter}>
<Text className={styles.timestamp}>{timestamp}</Text>
<Text className={styles.role}>{message.role}</Text>
<div className={styles.footerDetails}>
<Text className={styles.role}>{message.role}</Text>
{message.score && <MessageScore score={message.score} messageIndex={index} />}
</div>
</div>
</div>
</div>
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