Skip to content
Draft
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
26 changes: 26 additions & 0 deletions frontend/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,11 +570,37 @@ test.describe("Mobile touch targets", () => {
'[data-testid="copy-to-new-conv-btn-1"]',
'[data-testid="branch-conv-btn-1"]',
'[data-testid="branch-attack-btn-1"]',
'[aria-label="Add manual score"]',
].join(",")
)
);
await expectNoDocumentOverflow(page);

const manualScoreButton = page.getByRole("button", {
name: "Add manual score",
});
await manualScoreButton.click();
await page.getByRole("radio", { name: "Float scale" }).click();
await page.setViewportSize({ width: 320, height: 568 });

const manualScorePopover = page.getByTestId("manual-score-popover");
await expect(manualScorePopover).toBeVisible();
const popoverBounds = await manualScorePopover.boundingBox();
if (!popoverBounds) {
throw new Error("Expected manual score popover bounds");
}
expect(popoverBounds.y).toBeGreaterThanOrEqual(0);
expect(popoverBounds.y + popoverBounds.height).toBeLessThanOrEqual(568);
await expectMinimumTouchTarget(
page.getByRole("button", { name: "Cancel" })
);
await expectMinimumTouchTarget(
page.getByRole("button", { name: "Save" })
);
await expectNoDocumentOverflow(page);
await page.keyboard.press("Escape");
await page.setViewportSize({ width: 320, height: MOBILE_VIEWPORT.height });

await page.getByTestId("toggle-panel-btn").click();
await expect(
page.getByRole("dialog", { name: "Attack Conversations" })
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -375,7 +375,7 @@ function App() {
navigate(VIEW_PATHS.chat)
}, [navigate])

const handleConversationCreated = useCallback((arId: string, convId: string) => {
const handleConversationCreated = useCallback((arId: string, convId: string, objective?: string) => {
// Seed the freshly-created attack synchronously and tell the loader to skip
// its next fetch for this id, so the attack opens without a redundant load.
if (activeTarget) {
Expand Down Expand Up @@ -404,14 +404,18 @@ function App() {
labels: null,
target,
relatedConversationIds: [],
objective: '',
objective: objective ?? '',
status: 'success',
})
// Replace when promoting an empty /chat to its attack url (first message);
// push when branching from an existing attack so Back returns to the source.
navigate(attackRoutePath(arId), { replace: routeAttackId === null })
}, [activeTarget, handleSetActiveTarget, routeAttackId, navigate])

const handleObjectiveChange = useCallback((objective: string) => {
setLoadedAttack((current) => current ? { ...current, objective } : current)
}, [])

const handleSelectConversation = useCallback((convId: string) => {
if (!routeAttackId) return
navigate(attackConversationRoutePath(routeAttackId, convId, scenarioResultId))
Expand All @@ -437,6 +441,7 @@ function App() {
activeConversationId={activeConversationId}
onConversationCreated={handleConversationCreated}
onSelectConversation={handleSelectConversation}
onObjectiveChange={handleObjectiveChange}
labels={globalLabels}
onLabelsChange={handleGlobalLabelsChange}
onNavigate={handleNavigate}
Expand Down
89 changes: 85 additions & 4 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,13 +28,17 @@ jest.setTimeout(60000);
jest.mock("../../services/api", () => ({
attacksApi: {
createAttack: jest.fn(),
updateAttack: jest.fn(),
addMessage: jest.fn(),
getMessages: jest.fn(),
getRelatedConversations: jest.fn(),
getConversations: jest.fn(),
createConversation: jest.fn(),
changeMainConversation: jest.fn(),
},
scoresApi: {
createManualScore: jest.fn(),
},
convertersApi: {
listConverterCatalog: jest.fn(),
listConverters: jest.fn(),
Expand Down Expand Up @@ -526,6 +530,7 @@ describe("ChatWindow Integration", () => {
// Banner in ChatInputArea area
expect(screen.getByTestId("no-target-banner")).toBeInTheDocument();
expect(screen.getByTestId("configure-target-input-btn")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /add objective/i })).not.toBeInTheDocument();
});

it("should call onNewAttack when New Attack button is clicked", async () => {
Expand Down Expand Up @@ -707,7 +712,7 @@ describe("ChatWindow Integration", () => {
target_registry_name: "openai_chat_1",
labels: { operator: 'testuser', operation: 'test_op' },
});
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-1", "conv-1");
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-1", "conv-1", undefined);
expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith("ar-conv-1", {
role: "user",
pieces: [{ data_type: "text", original_value: "Hello" }],
Expand All @@ -724,6 +729,49 @@ describe("ChatWindow Integration", () => {
});
});

it("should persist a new conversation objective when the first message creates the attack", async () => {
const user = userEvent.setup();
const onConversationCreated = jest.fn();
mockedMapper.buildMessagePieces.mockResolvedValue([
{ data_type: "text", original_value: "Hello" },
]);
mockedAttacksApi.createAttack.mockResolvedValue({
attack_result_id: "ar-objective",
conversation_id: "conv-objective",
created_at: "2026-01-01T00:00:00Z",
});
mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Hello back!") as never);
mockedMapper.backendMessagesToFrontend.mockReturnValue([]);

render(
<TestWrapper>
<ChatWindow
{...defaultProps}
onConversationCreated={onConversationCreated}
/>
</TestWrapper>
);

await user.click(screen.getByRole("button", { name: /add objective/i }));
await user.type(screen.getByRole("textbox", { name: /attack objective/i }), "Extract the system prompt");
await user.click(screen.getByRole("button", { name: "Save" }));
await user.type(screen.getByRole("textbox"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
expect.objectContaining({
name: "Extract the system prompt",
})
);
expect(onConversationCreated).toHaveBeenCalledWith(
"ar-objective",
"conv-objective",
"Extract the system prompt",
);
});
});

// -----------------------------------------------------------------------
// System prompt (system_prompt) wiring
// -----------------------------------------------------------------------
Expand Down Expand Up @@ -1639,7 +1687,7 @@ describe("ChatWindow Integration", () => {

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalledTimes(1);
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-multi-turn", "conv-multi-turn");
expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-multi-turn", "conv-multi-turn", undefined);
});

// Now rerender with the conversation ID set (simulating parent state update)
Expand Down Expand Up @@ -2580,7 +2628,20 @@ describe("ChatWindow Integration", () => {
const onConversationCreated = jest.fn();
const existingMessages: Message[] = [
{ role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
{ role: "assistant", content: "response", timestamp: "2026-01-01T00:00:01Z" },
{
role: "assistant",
content: "response",
timestamp: "2026-01-01T00:00:01Z",
displayPieces: [
{
type: "text",
pieceId: "locked-response",
pieceIndex: 0,
content: "response",
scores: [],
},
],
},
];

const differentTarget: TargetInfo = {
Expand Down Expand Up @@ -2641,7 +2702,20 @@ describe("ChatWindow Integration", () => {
it("should show operator locked banner and use-as-template when operator differs", async () => {
const existingMessages: Message[] = [
{ role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
{ role: "assistant", content: "response", timestamp: "2026-01-01T00:00:01Z" },
{
role: "assistant",
content: "response",
timestamp: "2026-01-01T00:00:01Z",
displayPieces: [
{
type: "text",
pieceId: "locked-response",
pieceIndex: 0,
content: "response",
scores: [],
},
],
},
];

mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
Expand All @@ -2665,6 +2739,13 @@ describe("ChatWindow Integration", () => {
});

expect(screen.getByTestId("use-as-template-btn")).toBeInTheDocument();
const manualScoreButton = await screen.findByRole("button", { name: "Add manual score" });
await userEvent.click(manualScoreButton);
expect(screen.getByTestId("manual-score-objective-warning")).toHaveTextContent(
"An objective is required before you can add a manual score."
);
expect(screen.getByRole("textbox", { name: /attack objective/i })).toBeInTheDocument();
expect(screen.queryByText("Manual score")).not.toBeInTheDocument();
});

// -----------------------------------------------------------------------
Expand Down
51 changes: 47 additions & 4 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,14 @@ import type { PieceConversion } from './converterTypes'
import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes'
import LabelsBar from '../Labels/LabelsBar'
import type { ChatInputAreaHandle } from './ChatInputArea'
import { attacksApi } from '../../services/api'
import { attacksApi, scoresApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper'
import { exportConversation } from '../../utils/conversationExport'
import type { ExportFormat } from '../../utils/conversationExport'
import type {
AttackTargetResolutionStatus,
ManualScoreInput,
Message,
MessageAttachment,
TargetInstance,
Expand Down Expand Up @@ -84,8 +85,9 @@ interface ChatWindowProps {
attackResultId: string | null
conversationId: string | null
activeConversationId: string | null
onConversationCreated: (attackResultId: string, conversationId: string) => void
onConversationCreated: (attackResultId: string, conversationId: string, objective?: string) => void
onSelectConversation: (conversationId: string) => void
onObjectiveChange?: (objective: string) => void
labels?: Record<string, string>
onLabelsChange?: (labels: Record<string, string>) => void
onNavigate?: (view: ViewName) => void
Expand Down Expand Up @@ -115,6 +117,7 @@ export default function ChatWindow({
activeConversationId,
onConversationCreated,
onSelectConversation,
onObjectiveChange,
labels,
onLabelsChange,
onNavigate,
Expand All @@ -131,6 +134,8 @@ export default function ChatWindow({
const restoreFocusTargetAttributes = useRestoreFocusTarget()
const restoreFocusSourceAttributes = useRestoreFocusSource()
const [messages, setMessages] = useState<Message[]>([])
const [pendingObjective, setPendingObjective] = useState('')
const [objectiveEditRequestId, setObjectiveEditRequestId] = useState(0)
// Track sending state per conversation so parallel conversations can send independently
const [sendingConversations, setSendingConversations] = useState<Set<string>>(new Set())
/** True while an async message fetch is in-flight */
Expand Down Expand Up @@ -264,6 +269,7 @@ export default function ChatWindow({
setMessages([])
setLoadedConversationId(null)
setSystemPrompt('')
setPendingObjective('')
}
}

Expand Down Expand Up @@ -430,6 +436,7 @@ export default function ChatWindow({
if (!currentAttackResultId) {
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
name: pendingObjective || undefined,
labels: labels,
system_prompt: supportsSystemPrompt ? systemPrompt.trim() || undefined : undefined,
})
Expand All @@ -446,7 +453,7 @@ export default function ChatWindow({
pendingUserMessagesRef.current.delete('__pending__')
pendingUserMessagesRef.current.set(currentConversationId!, pendingMsgs)
}
onConversationCreated(currentAttackResultId, currentConversationId)
onConversationCreated(currentAttackResultId, currentConversationId, pendingObjective || undefined)
// Update the viewed-conversation ref so the success/error guards
// below recognise this as the active conversation.
viewedConvRef.current = currentConversationId!
Expand Down Expand Up @@ -690,6 +697,27 @@ export default function ChatWindow({
isMutationLocked,
])

const handleManualScore = useCallback(async (messageId: string, score: ManualScoreInput) => {
if (!attackResultId || !activeConversationId || !(objective || pendingObjective).trim()) return

await scoresApi.createManualScore({
attack_result_id: attackResultId,
message_id: messageId,
...score,
})
await loadConversation(attackResultId, activeConversationId)
}, [activeConversationId, attackResultId, loadConversation, objective, pendingObjective])

const handleAddObjective = useCallback(async (newObjective: string): Promise<void> => {
if (!attackResultId) {
setPendingObjective(newObjective)
return
}

const updatedAttack = await attacksApi.updateAttack(attackResultId, { objective: newObjective })
onObjectiveChange?.(updatedAttack.objective)
}, [attackResultId, onObjectiveChange])

const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user')

// "Continue with your target" — clone the current conversation into a new attack
Expand Down Expand Up @@ -869,7 +897,19 @@ export default function ChatWindow({
</Tooltip>
</div>
</div>
<ObjectiveHeader key={objective} objective={objective} />
<ObjectiveHeader
key={`${attackResultId ?? 'new'}-${objective}-${pendingObjective}-${objectiveEditRequestId}`}
objective={objective || pendingObjective}
canAdd={
Boolean(activeTarget)
&& !isLoadingAttack
&& !isLoadingMessages
&& !awaitingConversationLoad
&& messages.length === 0
}
onAdd={handleAddObjective}
editRequestId={objectiveEditRequestId}
/>
{systemMessage && <SystemPromptBanner content={systemMessage.content} />}
<MessageList
messages={messages}
Expand All @@ -883,6 +923,9 @@ export default function ChatWindow({
isCrossTarget={isCrossTargetLocked || isTargetResolutionLocked}
noTargetSelected={!activeTarget}
globalMarkdown={globalMarkdown}
onManualScore={attackResultId ? handleManualScore : undefined}
canManualScore={Boolean((objective || pendingObjective).trim())}
onManualScoreObjectiveRequired={() => setObjectiveEditRequestId(requestId => requestId + 1)}
/>
<ChatInputArea
ref={inputBoxRef}
Expand Down
Loading