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
50 changes: 47 additions & 3 deletions frontend/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,17 @@ 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: "true_false",
score_value: scoreIndex === 0 ? "true" : "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 @@ -217,6 +227,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 @@ -406,8 +418,39 @@ test.describe("Mobile touch targets", () => {
test("keeps Chat message, input, and conversation controls at least 44px", async ({
page,
}) => {
await page.goto("/");
await startChatWithMessages(page);
// 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 scoreChips = page.locator('[data-testid^="message-score-1-"]');
await expect(scoreChips).toHaveCount(1);
await expectMinimumTouchTargets(scoreChips);
const scoreMenu = page.getByTestId("message-score-menu-1");
await expect(scoreMenu).toBeVisible();
await expectMinimumTouchTarget(scoreMenu);
await scoreMenu.click();
const scoreMenuItems = page.locator(
'[data-testid^="message-score-menu-item-1-"]'
);
await expect(scoreMenuItems).toHaveCount(9);
await expectMinimumTouchTargets(scoreMenuItems);

await expectMinimumTouchTargets(
page.locator(
Expand All @@ -418,6 +461,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
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
70 changes: 69 additions & 1 deletion frontend/src/components/Chat/MessageList.styles.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { makeStyles, tokens } from '@fluentui/react-components'
import { NARROW_VIEWPORT_QUERY, mobileTouchTarget } from '../../styles/touchTargets'
import { NARROW_VIEWPORT_QUERY, mobileTouchTarget, mobileTouchTargetHeight } from '../../styles/touchTargets'

export const useMessageListStyles = makeStyles({
root: {
Expand Down Expand Up @@ -70,6 +70,68 @@ export const useMessageListStyles = makeStyles({
color: tokens.colorNeutralForeground3,
fontStyle: 'italic',
},
footerDetails: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXS,
},
pieceRow: {
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
rowGap: tokens.spacingVerticalXS,
width: '100%',
minWidth: 0,
maxWidth: '100%',
},
scoreList: {
Comment thread
jbolor21 marked this conversation as resolved.
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
flexWrap: 'wrap',
gap: tokens.spacingHorizontalXXS,
flexShrink: 0,
alignSelf: 'flex-end',
},
scoreChip: {
minWidth: '32px',
height: '32px',
padding: `0 ${tokens.spacingHorizontalXS}`,
...mobileTouchTarget,
},
scoreMenuItem: {
...mobileTouchTargetHeight,
},
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 All @@ -85,10 +147,16 @@ export const useMessageListStyles = makeStyles({
flexWrap: 'wrap',
gap: tokens.spacingHorizontalS,
marginTop: tokens.spacingVerticalS,
width: '100%',
minWidth: 0,
maxWidth: '100%',
},
attachmentItem: {
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
rowGap: tokens.spacingVerticalXS,
width: '100%',
minWidth: 0,
maxWidth: '100%',
},
Expand Down
Loading