Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
332c82b
initial commit adding scores per message
jbolor21 Aug 7, 2026
3eeaa8b
address comments
jbolor21 Aug 12, 2026
ced7f56
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 12, 2026
089428a
addressing feedback, clean up label
jbolor21 Aug 13, 2026
3a929c8
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 13, 2026
46af7bb
adding multiple scores per message
jbolor21 Aug 13, 2026
642120d
address feedback, add scores inline with message piece and dropdown f…
jbolor21 Aug 13, 2026
635dd0e
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 13, 2026
3679616
precommit
jbolor21 Aug 14, 2026
c1f4e67
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 14, 2026
021b22e
addressing comments
jbolor21 Aug 15, 2026
b9ec3f9
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 15, 2026
d98c629
feedback edits
jbolor21 Aug 15, 2026
f463c84
fixing multiple scores view
jbolor21 Aug 16, 2026
a89ac6b
fix unit tests
jbolor21 Aug 17, 2026
468a58c
got rid of tab scrolling
jbolor21 Aug 18, 2026
0c8ee98
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 18, 2026
547ec66
fix e2e test
jbolor21 Aug 18, 2026
dec4fbf
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 18, 2026
7eb2890
increased tabs before overflow and static score display when multiple…
jbolor21 Aug 20, 2026
d8b0ff0
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 20, 2026
5511d88
adding more scores to label
jbolor21 Aug 20, 2026
ce425c9
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 20, 2026
beaef95
address comments, rid of custom tab ui
jbolor21 Aug 20, 2026
b1d4113
fixing docstring
jbolor21 Aug 20, 2026
fda4c41
Merge remote-tracking branch 'origin' into jbolor/GUI_load_message_sc…
jbolor21 Aug 20, 2026
74cb43c
fix e2e test
jbolor21 Aug 20, 2026
1e82b57
address comments
jbolor21 Aug 24, 2026
6339ae9
test improvements
jbolor21 Aug 25, 2026
241c92f
minor feedback edits
jbolor21 Aug 25, 2026
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
98 changes: 95 additions & 3 deletions frontend/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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",
},
],
Expand Down Expand Up @@ -248,6 +262,8 @@ async function installTouchTargetMocks(page: Page): Promise<void> {
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",
Expand Down Expand Up @@ -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("/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(
Expand All @@ -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"]',
Comment thread
jbolor21 marked this conversation as resolved.
'[data-testid="chat-input"]',
'[data-testid="send-message-btn"]',
'[data-testid="copy-to-input-btn-1"]',
Expand Down
22 changes: 22 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
conversationId,
activeConversationId,
attackTarget,
objective,
targetResolutionStatus,
onRetryTargetResolution,
onConversationCreated,
Expand All @@ -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;
Expand All @@ -156,6 +158,7 @@ jest.mock("./components/Chat/ChatWindow", () => {
{(activeTarget as { target_registry_name?: string } | null)?.target_registry_name ?? "none"}
</span>
<span data-testid="attack-target-hash">{attackTarget?.identifier_hash ?? "none"}</span>
<span data-testid="objective">{objective ?? ""}</span>
<span data-testid="target-resolution-status">{targetResolutionStatus ?? "none"}</span>
<span data-testid="labels-operator">{labels.operator ?? ""}</span>
<span data-testid="labels-json">{JSON.stringify(labels)}</span>
Expand Down Expand Up @@ -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: [],
});
Expand All @@ -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("/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 () => {
Expand Down
6 changes: 6 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ interface LoadedAttack {
labels: Record<string, string> | null
target: TargetInfo | null
relatedConversationIds: string[]
objective: string
status: AttackLoadStatus
}

Expand Down Expand Up @@ -199,6 +200,7 @@ function App() {
labels: null,
target: null,
relatedConversationIds: [],
objective: '',
})
attacksApi
.getAttack(routeAttackId)
Expand All @@ -212,6 +214,7 @@ function App() {
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
objective: attack.objective ?? '',
status: 'success',
})
})
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -359,6 +364,7 @@ function App() {
onRetryTargetResolution={retryTargetResolution}
isLoadingAttack={isLoadingAttack}
relatedConversationCount={readyAttack ? readyAttack.relatedConversationIds.length : 0}
objective={readyAttack ? readyAttack.objective : ''}
/>
)

Expand Down
56 changes: 56 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-copy-score-only"
conversationId="conv-copy-score-only"
activeConversationId="conv-copy-score-only"
/>
</TestWrapper>
);

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
// ---------------------------------------------------------------------------
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 @@ -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({
Expand All @@ -113,6 +116,7 @@ export default function ChatWindow({
onRetryTargetResolution,
isLoadingAttack,
relatedConversationCount,
objective = '',
}: ChatWindowProps) {
const styles = useChatWindowStyles()
const restoreFocusTargetAttributes = useRestoreFocusTarget()
Expand Down Expand Up @@ -809,6 +813,7 @@ export default function ChatWindow({
</Tooltip>
</div>
</div>
<ObjectiveHeader key={objective} objective={objective} />
{systemMessage && <SystemPromptBanner content={systemMessage.content} />}
<MessageList
messages={messages}
Expand Down
Loading