diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index ecc0a13a05..34b706206c 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -4,6 +4,7 @@ import { makeTarget } from "./_targets"; const MOBILE_VIEWPORT = { width: 390, height: 844 }; const DESKTOP_VIEWPORT = { width: 1280, height: 800 }; const MINIMUM_TOUCH_TARGET_SIZE = 44; +const LONG_SCORE_VALUE = "a".repeat(200); const TARGETS = [ makeTarget({ @@ -74,7 +75,20 @@ const MESSAGES = [ converted_value_data_type: "text", original_value: "Deterministic assistant response for touch-target tests.", converted_value: "Deterministic assistant response for touch-target tests.", - scores: [], + scores: Array.from({ length: 9 }, (_unused: unknown, scoreIndex: number) => ({ + id: `mobile-assistant-score-${scoreIndex}`, + message_piece_id: "mobile-assistant-piece", + scorer_type: `SelfAskRefusalScorer${scoreIndex}`, + score_type: scoreIndex === 0 ? "unknown" : "true_false", + score_value: + scoreIndex === 0 + ? LONG_SCORE_VALUE + : "false", + is_objective_score: scoreIndex === 0, + score_category: ["refusal"], + score_rationale: `Deterministic rationale ${scoreIndex} for touch-target tests.`, + timestamp: `2026-07-22T13:10:0${scoreIndex}.500Z`, + })), response_error: "none", }, ], @@ -248,6 +262,8 @@ async function installTouchTargetMocks(page: Page): Promise { attack_type: "PromptSendingAttack", conversation_id: "mobile-conversation-001", related_conversation_ids: [], + objective: + "Deterministic long objective that does not fit on a single line of the mobile objective header and must be truncated with a disclosure toggle.", labels: { operator: "mobile_operator", operation: "touch_targets", @@ -446,8 +462,83 @@ test.describe("Mobile touch targets", () => { test("keeps Chat message, input, and conversation controls at least 44px", async ({ page, }) => { - await page.goto("/"); - await startChatWithMessages(page); + await page.setViewportSize({ width: 320, height: MOBILE_VIEWPORT.height }); + // Deep-link directly into the attack (rather than creating one through + // the chat flow) so the objective is actually hydrated from the backend: + // the create-attack flow seeds the objective as "" client-side and never + // loads the long mocked objective, so the disclosure toggle would never + // render and this test would silently skip checking it. + await page.goto("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/attacks/mobile-attack-001"); + await expect( + page.getByText("Deterministic assistant response for touch-target tests.") + ).toBeVisible(); + await expect( + page.getByTestId("toggle-objective-header-btn") + ).toBeVisible(); + + await page.getByRole("button", { name: "Configuration", exact: true }).click(); + await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); + await page.getByRole("button", { name: "Set Active" }).first().click(); + await page.goBack(); + await expect( + page.getByTestId("toggle-objective-header-btn") + ).toBeVisible(); + + const scoreStack = page.getByTestId("message-score-stack-1"); + await expect(scoreStack).toBeVisible(); + await expectMinimumTouchTarget(scoreStack); + await scoreStack.click(); + + const scoreDetails = page.locator( + '[data-testid^="message-score-details-1-"]' + ); + const scoreValue = scoreDetails.getByText(LONG_SCORE_VALUE, { exact: true }); + await expect(scoreValue).toBeVisible(); + const scoreGeometry = await scoreDetails.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(scoreGeometry.scrollWidth).toBeLessThanOrEqual( + scoreGeometry.clientWidth + ); + const valueGeometry = await scoreValue.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(valueGeometry.scrollWidth).toBeLessThanOrEqual( + valueGeometry.clientWidth + ); + await expectNoDocumentOverflow(page); + + const scoreTabs = page.locator('[data-testid^="message-score-tab-1-"]'); + await expect(scoreTabs).toHaveCount(2); + await expectMinimumTouchTargets(scoreTabs); + await scoreTabs.nth(1).click(); + + const shortScoreValue = scoreDetails.getByText("false", { exact: true }); + await expect(shortScoreValue).toBeVisible(); + const shortValueGeometry = await shortScoreValue.evaluate((element) => ({ + valueWidth: element.getBoundingClientRect().width, + rowWidth: element.parentElement?.getBoundingClientRect().width ?? 0, + })); + expect(shortValueGeometry.valueWidth).toBeLessThan( + shortValueGeometry.rowWidth + ); + + const moreScores = page.getByRole("button", { + name: "More scores, 7 hidden", + }); + await expect(moreScores).toBeVisible(); + await expectMinimumTouchTarget(moreScores); + await moreScores.click(); + + const scoreMenuItems = page.getByRole("menuitem"); + await expect(scoreMenuItems).toHaveCount(7); + await expectMinimumTouchTargets(scoreMenuItems); + await page.keyboard.press("Escape"); + await expect(scoreMenuItems).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(scoreStack).toHaveAttribute("aria-expanded", "false"); await expectMinimumTouchTargets( page.locator( @@ -458,6 +549,7 @@ test.describe("Mobile touch targets", () => { '[data-testid="new-attack-btn"]', '[aria-label="Attach files"]', '[data-testid="toggle-converter-panel-btn"]', + '[data-testid="toggle-objective-header-btn"]', '[data-testid="chat-input"]', '[data-testid="send-message-btn"]', '[data-testid="copy-to-input-btn-1"]', diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bb8e88b004..0fad90a584 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -128,6 +128,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId, activeConversationId, attackTarget, + objective, targetResolutionStatus, onRetryTargetResolution, onConversationCreated, @@ -140,6 +141,7 @@ jest.mock("./components/Chat/ChatWindow", () => { conversationId: string | null; activeConversationId: string | null; attackTarget?: { identifier_hash?: string | null } | null; + objective?: string; targetResolutionStatus?: string; onRetryTargetResolution?: () => void; onConversationCreated: (attackResultId: string, conversationId: string) => void; @@ -156,6 +158,7 @@ jest.mock("./components/Chat/ChatWindow", () => { {(activeTarget as { target_registry_name?: string } | null)?.target_registry_name ?? "none"} {attackTarget?.identifier_hash ?? "none"} + {objective ?? ""} {targetResolutionStatus ?? "none"} {labels.operator ?? ""} {JSON.stringify(labels)} @@ -752,6 +755,7 @@ describe("App", () => { mockGetAttack.mockResolvedValue({ attack_result_id: "ar-1", conversation_id: "conv-main", + objective: "Extract the hidden system prompt", labels: {}, related_conversation_ids: [], }); @@ -763,6 +767,24 @@ 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("hides the normalized empty objective of an unnamed manual attack on reload", async () => { + mockGetAttack.mockResolvedValue({ + attack_result_id: "ar-1", + conversation_id: "conv-main", + objective: "", + labels: {}, + related_conversation_ids: [], + }); + renderApp("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/attacks/ar-1"); + + await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-1")); + await waitFor(() => + expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-main") + ); + expect(screen.getByTestId("objective")).toHaveTextContent(""); }); it("uses the conversation from a deep link when it belongs to the attack", async () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8e05c3af03..42890c1b73 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -61,6 +61,7 @@ interface LoadedAttack { labels: Record | null target: TargetInfo | null relatedConversationIds: string[] + objective: string status: AttackLoadStatus } @@ -199,6 +200,7 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', }) attacksApi .getAttack(routeAttackId) @@ -212,6 +214,7 @@ function App() { labels: attack.labels ?? {}, target: attack.target ?? null, relatedConversationIds: attack.related_conversation_ids ?? [], + objective: attack.objective ?? '', status: 'success', }) }) @@ -230,6 +233,7 @@ function App() { labels: null, target: null, relatedConversationIds: [], + objective: '', }) }) // Drop a stale response once the route has moved on to another attack. @@ -318,6 +322,7 @@ function App() { labels: null, target, relatedConversationIds: [], + objective: '', status: 'success', }) // Replace when promoting an empty /chat to its attack url (first message); @@ -359,6 +364,7 @@ function App() { onRetryTargetResolution={retryTargetResolution} isLoadingAttack={isLoadingAttack} relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0} + objective={readyAttack ? readyAttack.objective : ''} /> ) diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index ad3da9d594..c108103354 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -3097,6 +3097,62 @@ describe("ChatWindow Integration", () => { }); }); + it("should not copy a score-only media piece into the input box", async () => { + const mockMessages: Message[] = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: "Blocked media response", + displayPieces: [ + { + type: "media", + pieceId: "piece-blocked", + pieceIndex: 0, + scores: [ + { + id: "score-blocked", + message_piece_id: "piece-blocked", + scorer_type: "ImageScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "image_path", + sourceLabel: "Piece 1 · image_path", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ], + }, + ]; + + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages); + + render( + + + + ); + + await waitFor(() => { + expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument(); + }); + + await userEvent.click(screen.getByTestId("copy-to-input-btn-1")); + + await waitFor(() => { + const textarea = screen.getByRole("textbox") as HTMLTextAreaElement; + expect(textarea.value).toBe("Blocked media response"); + }); + expect(screen.queryByTestId("remove-attachment-0")).not.toBeInTheDocument(); + }); + // --------------------------------------------------------------------------- // Converter panel integration // --------------------------------------------------------------------------- diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 1bcd7cf34d..61d4b535f6 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -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' @@ -94,6 +95,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({ @@ -113,6 +116,7 @@ export default function ChatWindow({ onRetryTargetResolution, isLoadingAttack, relatedConversationCount, + objective = '', }: ChatWindowProps) { const styles = useChatWindowStyles() const restoreFocusTargetAttributes = useRestoreFocusTarget() @@ -809,6 +813,7 @@ export default function ChatWindow({ + {systemMessage && } = ({ children, }) => {children}; describe("MessageList", () => { + afterEach(() => { + if (originalClientWidthDescriptor) { + Object.defineProperty(HTMLElement.prototype, "clientWidth", originalClientWidthDescriptor); + } + }); + const mockMessages: Message[] = [ { role: "user", @@ -109,6 +117,1017 @@ 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(), + scores: [ + { + id: "score-1", + message_piece_id: "piece-1", + scorer_type: "SelfAskScaleScorer", + score_type: "float_scale", + score_value: "0.9", + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + score_category: ["harmful"], + score_rationale: "The response contains harmful content.", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const scoreButton = screen.getByRole("button", { + name: /score 0.9 from selfaskscalescorer, objective score/i, + }); + expect(scoreButton).toBeInTheDocument(); + expect(scoreButton).toHaveTextContent("0.9"); + + await user.click(scoreButton); + + expect(screen.getByText("float_scale")).toBeInTheDocument(); + expect(screen.getByText("SelfAskScaleScorer")).toBeInTheDocument(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + expect(screen.getByText("Piece 1 · text")).toBeInTheDocument(); + expect(screen.getByText("harmful")).toBeInTheDocument(); + expect(screen.getByText("The response contains harmful content.")).toBeInTheDocument(); + }); + + it("should preserve a long single-score value outside its ellipsized chip", async () => { + const user = userEvent.setup(); + const longScoreValue = "a".repeat(200); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-long", + message_piece_id: "piece-1", + scorer_type: "UnknownScorer", + score_type: "unknown", + score_value: longScoreValue, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const scoreButton = screen.getByRole("button", { + name: `Score ${longScoreValue} from UnknownScorer, Piece 1 · text`, + }); + await user.hover(scoreButton); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longScoreValue); + + await user.unhover(scoreButton); + await user.click(scoreButton); + expect(screen.getByTestId("message-score-details-0-0")).toHaveTextContent(longScoreValue); + }); + + it("should show a stacked score control with tabs for multiple scores", async () => { + const user = userEvent.setup(); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-new", + message_piece_id: "piece-2", + scorer_type: "NewScorer", + score_type: "float_scale", + score_value: "0.9", + pieceIndex: 1, + pieceType: "text", + sourceLabel: "Piece 2 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + { + id: "score-old", + message_piece_id: "piece-1", + scorer_type: "OldScorer", + score_type: "true_false", + score_value: "False", + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const stackedScoreButton = screen.getByRole("button", { + name: /view 2 scores, displayed score false from oldscorer, objective score/i, + }); + expect(stackedScoreButton).toBeInTheDocument(); + + await user.click(stackedScoreButton); + + expect(screen.getByRole("tablist", { name: "Scores" })).toBeInTheDocument(); + const objectiveTab = screen.getByRole("tab", { + name: /score false from oldscorer, objective score/i, + }); + const auxiliaryTab = screen.getByRole("tab", { + name: /score 0.9 from newscorer/i, + }); + expect(screen.getAllByRole("tab")).toEqual([objectiveTab, auxiliaryTab]); + expect(objectiveTab).toHaveTextContent("False"); + expect(objectiveTab).not.toHaveTextContent("OldScorer"); + expect(objectiveTab).not.toHaveTextContent("Objective"); + expect(auxiliaryTab).toHaveTextContent("0.9"); + expect(auxiliaryTab).not.toHaveTextContent("NewScorer"); + expect(objectiveTab).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", objectiveTab.id); + expect(screen.getByText("true_false")).toBeInTheDocument(); + expect(screen.getByText("OldScorer")).toBeInTheDocument(); + expect(screen.getByText("Yes")).toBeInTheDocument(); + + await user.hover(auxiliaryTab); + expect( + await screen.findByText("Score 0.9 from NewScorer, Piece 2 · text") + ).toBeInTheDocument(); + await user.unhover(auxiliaryTab); + + await user.click(auxiliaryTab); + + expect(auxiliaryTab).toHaveAttribute("aria-selected", "true"); + expect(screen.getByRole("tabpanel")).toHaveAttribute("aria-labelledby", auxiliaryTab.id); + expect(screen.getByText("float_scale")).toBeInTheDocument(); + expect(screen.getByText("NewScorer")).toBeInTheDocument(); + expect(screen.getByText("No")).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: /view 2 scores, displayed score false from oldscorer, objective score/i, + }) + ).toBeInTheDocument(); + expect(stackedScoreButton).toHaveTextContent("False"); + }); + + it("should preserve a long stacked-score value outside its ellipsized chip", async () => { + const user = userEvent.setup(); + const longScoreValue = "b".repeat(200); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-long", + message_piece_id: "piece-1", + scorer_type: "UnknownScorer", + score_type: "unknown", + score_value: longScoreValue, + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + { + id: "score-short", + message_piece_id: "piece-1", + scorer_type: "OtherScorer", + score_type: "true_false", + score_value: "False", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const stackedScoreButton = screen.getByRole("button", { + name: `View 2 scores, displayed score ${longScoreValue} from UnknownScorer, objective score, Piece 1 · text`, + }); + await user.hover(stackedScoreButton); + expect(await screen.findByRole("tooltip")).toHaveTextContent(longScoreValue); + + await user.unhover(stackedScoreButton); + await user.click(stackedScoreButton); + expect(screen.getByRole("tabpanel")).toHaveTextContent(longScoreValue); + }); + + it("should focus the selected score tab when reopening with the keyboard", async () => { + const user = userEvent.setup(); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-latest", + message_piece_id: "piece-1", + scorer_type: "ScaleScorer", + score_type: "float_scale", + score_value: "0.91", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + { + id: "score-true", + message_piece_id: "piece-1", + scorer_type: "BooleanScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const trigger = screen.getByRole("button", { + name: /view 2 scores, displayed score 0.91 from scalescorer/i, + }); + await user.click(trigger); + + const trueTab = screen.getByRole("tab", { + name: /score true from booleanscorer/i, + }); + await user.click(trueTab); + expect(trueTab).toHaveAttribute("aria-selected", "true"); + + await user.keyboard("{Escape}"); + expect(trigger).toHaveFocus(); + await user.keyboard("{Enter}"); + + const reopenedTrueTab = screen.getByRole("tab", { + name: /score true from booleanscorer/i, + }); + const reopenedLatestTab = screen.getByRole("tab", { + name: /score 0.91 from scalescorer/i, + }); + expect(reopenedTrueTab).toHaveAttribute("aria-selected", "true"); + expect(reopenedTrueTab).toHaveFocus(); + expect(reopenedLatestTab).not.toHaveFocus(); + }); + + it("should display the latest score when there is no objective score", async () => { + const user = userEvent.setup(); + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-old", + message_piece_id: "piece-1", + scorer_type: "OldScorer", + score_type: "true_false", + score_value: "False", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + { + id: "score-new", + message_piece_id: "piece-1", + scorer_type: "NewScorer", + score_type: "float_scale", + score_value: "0.9", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + const stackedScoreButton = screen.getByRole("button", { + name: /view 2 scores, displayed score 0.9 from newscorer/i, + }); + expect(stackedScoreButton).toHaveTextContent("0.9"); + + await user.click(stackedScoreButton); + await user.click(screen.getByRole("tab", { + name: /score false from oldscorer/i, + })); + + expect(screen.getByText("OldScorer")).toBeInTheDocument(); + expect(stackedScoreButton).toHaveTextContent("0.9"); + }); + + it("should not show a stacked control when the message has only one score", () => { + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored response", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-1", + message_piece_id: "piece-1", + scorer_type: "SoleScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /score true from solescorer/i }) + ).toBeInTheDocument(); + expect( + screen.queryByTestId("message-score-stack-0") + ).not.toBeInTheDocument(); + }); + + it("should show every score tab when they fit in the available space", async () => { + const user = userEvent.setup(); + const scores = ["FirstScorer", "ObjectiveScorer", "ThirdScorer", "OverflowScorer"].map( + (scorerType, index) => ({ + id: `score-${index}`, + message_piece_id: "piece-1", + scorer_type: scorerType, + score_type: "float_scale", + score_value: `${index}`, + is_objective_score: index === 1, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: `2026-02-15T00:0${index}:00Z`, + }) + ); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /view 4 scores/i })); + expect(screen.getAllByRole("tab")).toHaveLength(4); + expect( + screen.getByRole("tab", { name: /overflowscorer/i }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("button", { name: /more scores/i }) + ).not.toBeInTheDocument(); + }); + + it("should move only score tabs that do not fit into the More menu", async () => { + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return this.hasAttribute("data-score-tab-bar") ? 250 : 0; + }, + }); + const user = userEvent.setup(); + const scores = ["FirstScorer", "ObjectiveScorer", "ThirdScorer", "OverflowScorer"].map( + (scorerType, index) => ({ + id: `score-${index}`, + message_piece_id: "piece-1", + scorer_type: scorerType, + score_type: "float_scale", + score_value: `${index}`, + is_objective_score: index === 1, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: `2026-02-15T00:0${index}:00Z`, + }) + ); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /view 4 scores/i })); + expect(screen.getAllByRole("tab")).toHaveLength(2); + const objectiveTab = screen.getByRole("tab", { + name: /score 1 from objectivescorer, objective score/i, + }); + expect(objectiveTab).toHaveAttribute("aria-selected", "true"); + const moreScoresButton = screen.getByRole("button", { name: "More scores, 2 hidden" }); + expect(moreScoresButton).toHaveTextContent("More scores"); + await user.click(moreScoresButton); + expect(objectiveTab).toHaveAttribute("aria-selected", "true"); + + const overflowScore = screen.getByRole("menuitem", { + name: /3 · overflowscorer/i, + }); + expect(overflowScore).toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /objectivescorer/i }) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole("menuitem", { name: /firstscorer/i }) + ).not.toBeInTheDocument(); + + await user.click(overflowScore); + + expect( + screen.getByRole("tab", { name: /score 3 from overflowscorer/i }) + ).toHaveAttribute("aria-selected", "true"); + expect( + screen.queryByRole("tab", { name: /score 0 from firstscorer/i }) + ).not.toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "More scores, 2 hidden" })); + expect( + screen.getByRole("menuitem", { name: /0 · firstscorer/i }) + ).toBeInTheDocument(); + }); + + it("should keep another score tab visible when only one tab fits naturally", async () => { + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return this.hasAttribute("data-score-tab-bar") ? 150 : 0; + }, + }); + + const user = userEvent.setup(); + const scores = ["FirstScorer", "ObjectiveScorer", "ThirdScorer"].map( + (scorerType, index) => ({ + id: `score-${index}`, + message_piece_id: "piece-1", + scorer_type: scorerType, + score_type: "float_scale", + score_value: `${index}`, + is_objective_score: index === 1, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: `2026-02-15T00:0${index}:00Z`, + }) + ); + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /view 3 scores/i })); + expect(screen.getAllByRole("tab")).toHaveLength(2); + + await user.click(screen.getByRole("button", { name: "More scores, 1 hidden" })); + await user.click(screen.getByRole("menuitem", { name: /2 · thirdscorer/i })); + + expect(screen.getAllByRole("tab")).toHaveLength(2); + expect( + screen.getByRole("tab", { name: /score 2 from thirdscorer/i }) + ).toHaveAttribute("aria-selected", "true"); + expect( + screen.getByRole("tab", { name: /objectivescorer/i }) + ).toBeInTheDocument(); + }); + + it("should disambiguate identical overflow scores with piece, category, and ordinal context", async () => { + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get() { + return this.hasAttribute("data-score-tab-bar") ? 150 : 0; + }, + }); + const user = userEvent.setup(); + const scores = [ + { + id: "score-objective", + message_piece_id: "piece-1", + scorer_type: "ObjectiveScorer", + score_type: "true_false", + score_value: "True", + is_objective_score: true, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + { + id: "score-visible", + message_piece_id: "piece-1", + scorer_type: "VisibleScorer", + score_type: "float_scale", + score_value: "0.1", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:01:00Z", + }, + ...["piece-2", "piece-3", "piece-3"].map((pieceId, index) => ({ + id: `score-shared-${index}`, + message_piece_id: pieceId, + scorer_type: "SharedScorer", + score_type: "float_scale", + score_value: "0.5", + score_category: index === 0 ? ["alpha"] : ["beta"], + pieceIndex: index === 0 ? 1 : 2, + pieceType: "text", + sourceLabel: index === 0 ? "Piece 2 · text" : "Piece 3 · text", + timestamp: `2026-02-15T00:0${index + 2}:00Z`, + })), + ]; + + render( + + + + ); + + await user.click(screen.getByRole("button", { name: /view 5 scores/i })); + await user.click(screen.getByRole("button", { name: "More scores, 3 hidden" })); + + expect(screen.getByRole("menuitem", { + name: "0.5 · SharedScorer · Piece 2 · text · Categories: alpha", + })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { + name: "0.5 · SharedScorer · Piece 3 · text · Categories: beta · 1 of 2", + })).toBeInTheDocument(); + expect(screen.getByRole("menuitem", { + name: "0.5 · SharedScorer · Piece 3 · text · Categories: beta · 2 of 2", + })).toBeInTheDocument(); + }); + + it("should distinguish text and attachment score controls by source label", () => { + const sharedScores: Array> = [ + { + id: "score-objective", + scorer_type: "SharedScorer", + score_type: "true_false", + score_value: "True", + is_objective_score: true, + timestamp: "2026-02-15T00:00:00Z", + }, + { + id: "score-auxiliary", + scorer_type: "AuxiliaryScorer", + score_type: "float_scale", + score_value: "0.5", + timestamp: "2026-02-15T00:01:00Z", + }, + ]; + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "Scored text", + timestamp: new Date().toISOString(), + attachments: [ + { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + }, + ], + displayPieces: [ + { + type: "text", + pieceId: "piece-1", + pieceIndex: 0, + content: "Scored text", + scores: sharedScores.map((score) => ({ + ...score, + id: `${score.id}-text`, + message_piece_id: "piece-1", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + })), + }, + { + type: "media", + pieceId: "piece-2", + pieceIndex: 1, + attachment: { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + }, + scores: sharedScores.map((score) => ({ + ...score, + id: `${score.id}-image`, + message_piece_id: "piece-2", + pieceIndex: 1, + pieceType: "image_path", + sourceLabel: "Piece 2 · image_path · test.png", + })), + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { + name: "View 2 scores, displayed score True from SharedScorer, objective score, Piece 1 · text", + }) + ).toBeInTheDocument(); + expect( + screen.getByRole("button", { + name: "View 2 scores, displayed score True from SharedScorer, objective score, Piece 2 · image_path · test.png", + }) + ).toBeInTheDocument(); + }); + + it("should render ordered display pieces with only their own scores", () => { + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "First text\nSecond text", + timestamp: new Date().toISOString(), + displayPieces: [ + { + type: "text", + pieceId: "piece-1", + pieceIndex: 0, + content: "First text", + scores: [ + { + id: "score-1", + message_piece_id: "piece-1", + scorer_type: "FirstScorer", + score_type: "unknown", + score_value: "first-only", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + { + type: "media", + pieceId: "piece-2", + pieceIndex: 1, + attachment: { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + }, + scores: [ + { + id: "score-2", + message_piece_id: "piece-2", + scorer_type: "ImageScorer", + score_type: "unknown", + score_value: "image-only", + pieceIndex: 1, + pieceType: "image_path", + sourceLabel: "Piece 2 · image_path · test.png", + timestamp: "2026-02-15T00:01:00Z", + }, + ], + }, + { + type: "text", + pieceId: "piece-3", + pieceIndex: 2, + content: "Second text", + scores: [ + { + id: "score-3", + message_piece_id: "piece-3", + scorer_type: "SecondScorer", + score_type: "unknown", + score_value: "second-only", + pieceIndex: 2, + pieceType: "text", + sourceLabel: "Piece 3 · text", + timestamp: "2026-02-15T00:02:00Z", + }, + ], + }, + ], + }, + ]; + + render( + + + + ); + + const pieces = screen.getAllByTestId(/^message-piece-0-/); + expect(pieces).toHaveLength(3); + expect(pieces[0]).toHaveTextContent("First text"); + expect(within(pieces[0]).getByRole("button", { name: /score first-only from firstscorer/i })).toBeInTheDocument(); + expect(within(pieces[0]).queryByRole("button", { name: /second-only/i })).not.toBeInTheDocument(); + expect(within(pieces[1]).getByAltText("test.png")).toBeInTheDocument(); + expect(within(pieces[1]).getByRole("button", { name: /score image-only from imagescorer/i })).toBeInTheDocument(); + expect(pieces[2]).toHaveTextContent("Second text"); + expect(within(pieces[2]).getByRole("button", { name: /score second-only from secondscorer/i })).toBeInTheDocument(); + expect(within(pieces[2]).queryByRole("button", { name: /first-only/i })).not.toBeInTheDocument(); + }); + + it("should render a score-only media piece without an actionable attachment", async () => { + const user = userEvent.setup(); + const onCopyToInput = jest.fn(); + const scoreOnlyMessage: Message = { + role: "assistant", + content: "", + timestamp: new Date().toISOString(), + displayPieces: [ + { + type: "media", + pieceId: "piece-blocked", + pieceIndex: 0, + scores: [ + { + id: "score-blocked", + message_piece_id: "piece-blocked", + scorer_type: "ImageScorer", + score_type: "true_false", + score_value: "blocked-media", + pieceIndex: 0, + pieceType: "image_path", + sourceLabel: "Piece 1 · image_path", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ], + }; + + render( + + + + ); + + const piece = screen.getByTestId("message-piece-0-0"); + expect( + within(piece).getByRole("button", { name: /score blocked-media from imagescorer/i }) + ).toBeInTheDocument(); + expect(within(piece).queryByRole("img")).not.toBeInTheDocument(); + expect(screen.queryByTestId("download-btn-0-0")).not.toBeInTheDocument(); + + await user.click(screen.getByTestId("copy-to-input-btn-0")); + expect(onCopyToInput).toHaveBeenCalledWith(0); + }); + + it("should not offer a download action for an attachment with no URL", () => { + render( + + + + ); + + expect(screen.queryByTestId("download-btn-0-0")).not.toBeInTheDocument(); + }); + + it("should preserve the message-level score test ID for a single display piece", () => { const scores = ["FirstScorer", "SecondScorer"].map((scorerType, index) => ({ + id: `score-${index}`, + message_piece_id: "piece-1", + scorer_type: scorerType, + score_type: "float_scale", + score_value: `${index}`, + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: `2026-02-15T00:0${index}:00Z`, + })); + + render( + + + + ); + + expect(screen.getByTestId("message-score-stack-0")).toBeInTheDocument(); + }); + + it("should not show a score chip when the message has no score", () => { + render( + + + + ); + + expect( + screen.queryByRole("button", { name: /^score /i }) + ).not.toBeInTheDocument(); + }); + + it("should show a text score when the converted response is empty", () => { + const scoredMessages: Message[] = [ + { + role: "assistant", + content: "", + timestamp: new Date().toISOString(), + scores: [ + { + id: "score-empty-response", + message_piece_id: "piece-empty-response", + scorer_type: "EmptyResponseScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "text", + sourceLabel: "Piece 1 · text", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { + name: "Score True from EmptyResponseScorer, Piece 1 · text", + }) + ).toBeInTheDocument(); + }); + + it("should show a score chip next to the attachment it was computed on", () => { + const messagesWithScoredAttachment: Message[] = [ + { + role: "assistant", + content: "Here is a caption and a picture", + timestamp: new Date().toISOString(), + attachments: [ + { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + size: 1024, + }, + ], + displayPieces: [ + { + type: "media", + pieceId: "piece-image", + pieceIndex: 0, + attachment: { + type: "image", + name: "test.png", + url: "data:image/png;base64,iVBORw0KGgo=", + mimeType: "image/png", + size: 1024, + }, + scores: [ + { + id: "score-image", + message_piece_id: "piece-image", + scorer_type: "ImageScorer", + score_type: "true_false", + score_value: "True", + pieceIndex: 0, + pieceType: "image_path", + sourceLabel: "Piece 1 · image_path · test.png", + timestamp: "2026-02-15T00:00:00Z", + }, + ], + }, + ], + }, + ]; + + render( + + + + ); + + expect( + screen.getByRole("button", { name: /score true from imagescorer/i }) + ).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
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index b072b866f3..d6f846b940 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useRef, useState, useCallback } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
 import {
   Text,
   Avatar,
@@ -6,12 +6,31 @@ import {
   MessageBar,
   MessageBarBody,
   Button,
+  Badge,
+  Menu,
+  MenuItem,
+  MenuList,
+  MenuPopover,
+  MenuTrigger,
+  Popover,
+  PopoverSurface,
+  PopoverTrigger,
+  Tab,
+  TabList,
   Tooltip,
   Spinner,
   mergeClasses,
 } from '@fluentui/react-components'
-import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular, OpenRegular } from '@fluentui/react-icons'
-import { Message, MessageAttachment } from '../../types'
+import {
+  ArrowDownloadRegular,
+  ArrowForwardRegular,
+  ArrowReplyRegular,
+  BranchForkRegular,
+  ChatAddRegular,
+  MoreHorizontalRegular,
+  OpenRegular,
+} from '@fluentui/react-icons'
+import type { DisplayScore, Message, MessageAttachment, MessageDisplayPiece } from '../../types'
 import MarkdownContent from './MarkdownContent'
 import { useMessageListStyles } from './MessageList.styles'
 
@@ -84,6 +103,362 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio';
   return