diff --git a/frontend/src/components/Chat/ChatInputArea.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx
index 828976c607..0436d1b104 100644
--- a/frontend/src/components/Chat/ChatInputArea.test.tsx
+++ b/frontend/src/components/Chat/ChatInputArea.test.tsx
@@ -38,7 +38,6 @@ describe("ChatInputArea", () => {
const defaultProps = {
onSend: jest.fn().mockResolvedValue(sentOutcome),
disabled: false,
- onNewConversation: jest.fn(),
onUseAsTemplate: jest.fn(),
onConfigureTarget: jest.fn(),
onToggleConverterPanel: jest.fn(),
@@ -105,14 +104,15 @@ describe("ChatInputArea", () => {
it("should keep Undo reachable for an eligible first-turn undo when a single-turn prompt is disabled", async () => {
const user = userEvent.setup();
const onUndo = jest.fn();
- render();
expect(screen.getByRole("textbox")).toBeDisabled();
expect(screen.getByRole("button", { name: "Toggle converter panel" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Undo last send" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: "Undo last send" }));
expect(onUndo).toHaveBeenCalledTimes(1);
- expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
});
it("should retain the disabled Undo explanation in the prompt box", async () => {
@@ -934,7 +934,7 @@ describe("ChatInputArea", () => {
expect(
screen.getByText(
- /does not track conversation history/
+ /only supports single-turn conversations/
)
).toBeInTheDocument();
});
@@ -1200,62 +1200,30 @@ describe("ChatInputArea", () => {
expect(screen.queryByText(/\(\s*\d+(?:\.\d+)?\s*[KM]?B\s*\)/)).toBeNull();
});
- it("should show single-turn banner when singleTurnLimitReached is true", () => {
- render(
-
-
-
- );
-
- expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
- expect(screen.getByText(/only supports single-turn/)).toBeInTheDocument();
- expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
- // Input area should not be rendered
- expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
- });
-
- it("should call onNewConversation when New Conversation button clicked", async () => {
+ it.each([false, true])("keeps the disabled single-turn composer visible with Undo present=%s", async (withUndo: boolean) => {
const user = userEvent.setup();
- const onNewConversation = jest.fn();
-
- render(
-
-
-
- );
-
- await user.click(screen.getByTestId("new-conversation-btn"));
- expect(onNewConversation).toHaveBeenCalledTimes(1);
- });
-
- it("should show New Conversation button when singleTurnLimitReached", () => {
- render(
-
-
-
- );
-
- expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
- expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
+ render();
+ expect(screen.getByRole("textbox")).toBeDisabled();
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("new-conversation-btn")).not.toBeInTheDocument();
+ await user.click(screen.getByLabelText("Why the prompt box is disabled"));
+ expect(screen.getByRole("listitem")).toHaveTextContent("This target only supports single-turn conversations.");
+ expect(screen.queryByText(/Wait for the current operation/)).not.toBeInTheDocument();
+ await user.keyboard("{Escape}");
+ await user.hover(getSendButton());
+ expect(await screen.findByRole("tooltip")).toHaveTextContent("This target only supports single-turn conversations.");
+ await user.click(getSendButton());
+ expect(defaultProps.onSend).not.toHaveBeenCalled();
});
- it("should show normal input when singleTurnLimitReached is false", () => {
+ it("should show normal input before a single-turn send", () => {
render(
);
diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx
index a11d75e380..0a0086c7e3 100644
--- a/frontend/src/components/Chat/ChatInputArea.tsx
+++ b/frontend/src/components/Chat/ChatInputArea.tsx
@@ -1,7 +1,7 @@
import { useState, useCallback, useEffect, useLayoutEffect, useRef, forwardRef, useImperativeHandle, type KeyboardEvent } from 'react'
import { Button, Caption1, Popover, PopoverSurface, PopoverTrigger, Tooltip, Text } from '@fluentui/react-components'
-import { SendRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowSyncRegular } from '@fluentui/react-icons'
+import { SendRegular, DismissRegular, InfoRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowSyncRegular } from '@fluentui/react-icons'
import type { AttackTargetResolutionStatus, ChatSendOutcome, ConvertedFileChip, MessageAttachment, MultiSendOptions, PieceConversion, TargetInstance, UndoLastSendAction } from '@/types'
import { isTargetResolutionBlocking } from '@/utils/targetIdentity'
@@ -120,8 +120,6 @@ interface ChatInputAreaProps {
sendDisabled?: boolean
sendDisabledReasons?: string[]
activeTarget?: TargetInstance | null
- singleTurnLimitReached?: boolean
- onNewConversation?: () => void
operatorLocked?: boolean
crossTargetLocked?: boolean
targetResolutionStatus?: AttackTargetResolutionStatus
@@ -151,7 +149,7 @@ interface ChatInputAreaProps {
const ChatInputArea = forwardRef(function ChatInputArea({
onSend, conversionRevisionKey = '', disabled = false, disabledReasons = [], sendDisabled = false, sendDisabledReasons = [], activeTarget,
- singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false,
+ operatorLocked = false, crossTargetLocked = false,
targetResolutionStatus = 'idle', onRetryTargetResolution, onUseAsTemplate, attackOperator, onConfigureTarget,
onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange,
convertedValue, onClearConversion, onClearAllConversions, onConvertedValueChange,
@@ -288,17 +286,8 @@ const ChatInputArea = forwardRef(functi
text="This attack uses a different target. Continue with your target to keep the conversation."
buttonText="Continue with your target" buttonIcon={} onButtonClick={onUseAsTemplate}
testId="cross-target-banner" buttonTestId="use-as-template-btn" buttonClassName={styles.touchTarget} />
- ) : singleTurnLimitReached && !undoLastSend ? (
- }
- text="This target only supports single-turn conversations." buttonText="New Conversation" buttonIcon={}
- onButtonClick={onNewConversation} testId="single-turn-banner"
- buttonTestId="new-conversation-btn" buttonClassName={styles.touchTarget} />
) : (
<>
- {singleTurnLimitReached && } text="This target only supports single-turn conversations."
- buttonText="New Conversation" buttonIcon={} onButtonClick={onNewConversation}
- testId="single-turn-banner" buttonTestId="new-conversation-btn" buttonClassName={styles.touchTarget} />}
(functi
{activeTarget?.capabilities?.supports_multi_turn === false &&
+ content="This target only supports single-turn conversations." relationship="description">
}
diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx
index 9b2274844d..230cdf3d5a 100644
--- a/frontend/src/components/Chat/ChatWindow.test.tsx
+++ b/frontend/src/components/Chat/ChatWindow.test.tsx
@@ -1640,6 +1640,52 @@ describe("ChatWindow Integration", () => {
expect(await screen.findByPlaceholderText("Type prompt here")).toHaveValue("Original prompt");
});
+ it("restores matching in-memory converter stages when branching the first prompt", async () => {
+ const { user, selected } = await prepareUndoChat({ immutable: true, emptyHistory: true });
+ const attack = await mockedAttacksApi.getAttack("undo-attack");
+ mockedAttacksApi.saveConversation.mockResolvedValue({
+ attack, messages: { conversation_id: "undo-branch", messages: [], target_response_status: null },
+ });
+ await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]);
+ await user.click(screen.getByRole("menuitem", { name: "Branch conversation", exact: true }));
+ await waitFor(() => expect(selected).toHaveBeenCalledWith("undo-branch"));
+ expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original prompt");
+ expect(screen.getByRole("textbox", { name: "Stage 1 output - Text" })).toHaveValue("Custom middle text");
+ expect(screen.getByRole("textbox", { name: "Stage 2 output - Text" })).toHaveValue("Custom middle text|second");
+ expect(screen.getByRole("textbox", { name: "Converted prompt" })).toHaveValue("Custom middle text|second");
+ expect(mockedConvertersApi.previewConversion).toHaveBeenCalledTimes(2);
+ expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1);
+ expect(mockedAttacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({
+ destination: "same_attack", messages: [],
+ }));
+ });
+
+ it.each(["pipeline", "working input"])("protects %s edits before first-prompt restore", async (edit: string) => {
+ const { user, selected } = await prepareUndoChat({ immutable: true, emptyHistory: true });
+ if (edit === "pipeline") {
+ await user.click(screen.getByRole("button", { name: "Remove converter second" }));
+ } else {
+ await user.type(screen.getByRole("textbox", { name: "Working input - Text" }), "Keep my working input");
+ }
+ expect(screen.getByRole("button", { name: "Undo last send" })).toBeDisabled();
+ await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]);
+ const branch = screen.getByRole("menuitem", { name: "Branch conversation", exact: true });
+ expect(branch).toHaveAttribute("aria-disabled", "true");
+ expect(branch).toHaveAccessibleDescription(expect.stringMatching(
+ edit === "pipeline" ? /converter pipeline changed/i : /clear the current prompt and conversion edits/i,
+ ));
+ await user.click(branch);
+ expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled();
+ expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled();
+ expect(selected).not.toHaveBeenCalled();
+ if (edit === "pipeline") {
+ expect(screen.queryByRole("button", { name: "Remove converter second" })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Remove converter first" })).toBeInTheDocument();
+ } else {
+ expect(screen.getByRole("textbox", { name: "Working input - Text" })).toHaveValue("Keep my working input");
+ }
+ });
+
it("disables undo rather than overwriting a new prompt", async () => {
const { user } = await prepareUndoChat();
await user.type(screen.getByPlaceholderText("Type prompt here"), "Keep my new draft");
@@ -5257,7 +5303,8 @@ describe("ChatWindow Integration", () => {
await user.click(screen.getByRole("button", { name: "Send message" }));
const recoveryButton = await findRecoveryAction(user);
- expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeDisabled();
await user.click(recoveryButton);
await waitFor(() => {
@@ -5502,7 +5549,209 @@ describe("ChatWindow Integration", () => {
// Single-turn target UX
// -----------------------------------------------------------------------
- it("should show single-turn banner for single-turn target with existing user messages", async () => {
+ describe("first-prompt branching", () => {
+ async function setupFirstPromptChat(options: {
+ sent?: boolean; imageResponse?: boolean; convertedPrompt?: boolean; withAttachment?: boolean; restorable?: boolean;
+ } = {}) {
+ const user = userEvent.setup();
+ const target = makeTarget({
+ target_registry_name: "http-target", target_type: "HTTPTarget", supports_history_replay: options.restorable ?? false,
+ capabilities: buildCapabilities({
+ supports_multi_turn: false, supported_input_modalities: ["text", "image_path"],
+ supports_multi_message_pieces: true,
+ }),
+ });
+ const attack: AttackSummary = {
+ attack_result_id: "first-attack", conversation_id: "first-source", attack_type: "ManualAttack",
+ objective: "Keep this objective", converters: [], message_count: 2, related_conversation_ids: [],
+ labels: {}, created_at: "", updated_at: "",
+ };
+ const source = makeErrorResponse("none", "").messages;
+ source.messages[0].message_pieces[0] = {
+ ...source.messages[0].message_pieces[0], original_value: "Original request",
+ converted_value: options.convertedPrompt ? "Converted request" : "Original request",
+ prompt_metadata: { custom: "preserved" },
+ converter_identifiers: options.convertedPrompt ? [{ type: "SavedConverter" }] : [],
+ };
+ if (options.withAttachment) source.messages[0].message_pieces.push({
+ id: "original-image", original_value_data_type: "image_path", original_value: "original.png",
+ converted_value_data_type: "image_path", converted_value: "original.png",
+ original_filename: "evidence.png", original_value_mime_type: "image/png",
+ scores: [], response_error: "none",
+ });
+ source.messages[1].message_pieces[0] = {
+ ...source.messages[1].message_pieces[0],
+ original_value: options.imageResponse ? "response.png" : "Original response",
+ converted_value: options.imageResponse ? "response.png" : "Original response",
+ converted_value_data_type: options.imageResponse ? "image_path" : "text",
+ };
+ const empty = (id: string) => ({ conversation_id: id, messages: [], target_response_status: null });
+ const stored = new Map([
+ ["first-source", options.sent === false ? empty("first-source") : { ...source, conversation_id: "first-source" }],
+ ["other", empty("other")],
+ ]);
+ mockedAttacksApi.getMessages.mockImplementation(async (_attackId, conversationId) => {
+ const messages = stored.get(conversationId ?? "first-source");
+ if (!messages) throw new Error("Unexpected conversation");
+ return messages;
+ });
+ mockedAttacksApi.getAttack.mockResolvedValue(attack);
+ mockedAttacksApi.saveConversation.mockImplementation(async () => {
+ const messages = empty("first-branch");
+ stored.set("first-branch", messages);
+ return { attack, messages };
+ });
+ mockSendResult.mockImplementation(async (_attackId, request) => {
+ const messages = { ...source, conversation_id: request.target_conversation_id };
+ stored.set(request.target_conversation_id, messages);
+ return { attack, messages };
+ });
+ const selected = jest.fn();
+ function BranchHarness() {
+ const [conversation, setConversation] = useState("first-source");
+ return <>
+
+ { selected(id); setConversation(id); }} />
+ >;
+ }
+ render();
+ if (options.sent !== false) await screen.findByText(options.convertedPrompt ? "Converted request" : "Original request");
+ else await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled());
+ return { user, source, stored, selected, attack };
+ }
+
+ async function branchFirstPrompt(user: ReturnType): Promise {
+ await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[0]);
+ const branch = screen.getByRole("menuitem", { name: "Branch conversation", exact: true });
+ expect(branch).not.toHaveAttribute("aria-disabled", "true");
+ expect(branch).toHaveAccessibleDescription(
+ "Restore this prompt as an unsent draft in a new conversation in the same attack. The response is not copied.",
+ );
+ await user.click(branch);
+ }
+
+ it.each([
+ { imageResponse: false, restorable: false },
+ { imageResponse: true, restorable: false },
+ { imageResponse: false, restorable: true },
+ ])("keeps the composer mounted after an HTTP send with image response=$imageResponse and restorable=$restorable and can branch and resend", async ({
+ imageResponse, restorable,
+ }: { imageResponse: boolean; restorable: boolean }) => {
+ const { user, stored, selected } = await setupFirstPromptChat({ sent: false, imageResponse, restorable });
+ const composer = screen.getByPlaceholderText("Type prompt here");
+ await user.type(composer, "Original request");
+ await user.click(screen.getByRole("button", { name: "Send message" }));
+ await screen.findByText("Original request");
+ await waitFor(() => expect(composer).toBeDisabled());
+ expect(screen.getByPlaceholderText("Type prompt here")).toBe(composer);
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ if (imageResponse) expect(screen.queryByRole("button", { name: "Undo last send" })).not.toBeInTheDocument();
+ else await waitFor(() => expect(screen.getByRole("button", { name: "Undo last send" })).toBeEnabled());
+ await user.click(screen.getByLabelText("Why the prompt box is disabled"));
+ expect(screen.getByRole("listitem")).toHaveTextContent("This target only supports single-turn conversations.");
+ await user.keyboard("{Escape}");
+ for (const button of screen.getAllByRole("button", { name: "Copy or Branch" })) {
+ await user.click(button);
+ expect(screen.getByRole("menuitem", { name: "Copy to next message" })).toHaveAttribute("aria-disabled", "true");
+ await user.keyboard("{Escape}");
+ }
+ const sourceBeforeBranch = stored.get("first-source");
+ await branchFirstPrompt(user);
+ await waitFor(() => expect(selected).toHaveBeenCalledWith("first-branch"));
+ expect(mockedAttacksApi.saveConversation).toHaveBeenCalledWith(expect.objectContaining({
+ destination: "same_attack", attack_result_id: "first-attack", messages: [],
+ target_registry_name: "http-target", source_conversation_id: "first-source",
+ }));
+ expect(stored.get("first-source")).toBe(sourceBeforeBranch);
+ expect(composer).toBeEnabled();
+ expect(composer).toHaveValue("Original request");
+ expect(screen.getByRole("button", { name: "Edit objective" })).toHaveTextContent("Keep this objective");
+ expect(mockedAttacksApi.createAttack).not.toHaveBeenCalled();
+ expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1);
+ await user.click(screen.getByRole("button", { name: "Send message" }));
+ await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(2));
+ expect(mockedAttacksApi.submitMessageSend).toHaveBeenLastCalledWith("first-attack", expect.objectContaining({
+ target_conversation_id: "first-branch", pieces: [expect.objectContaining({ original_value: "Original request" })],
+ }));
+ expect(mockedConvertersApi.previewConversion).not.toHaveBeenCalled();
+ });
+
+ it("restores a saved first prompt with original values, conversions, attachments and metadata", async () => {
+ const { user } = await setupFirstPromptChat({ convertedPrompt: true, withAttachment: true });
+ await branchFirstPrompt(user);
+ await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled());
+ expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Original request");
+ expect(screen.getByRole("button", { name: "Remove evidence.png" })).toBeInTheDocument();
+ expect(screen.getByText(/Converter choices could not be restored/)).toBeInTheDocument();
+ expect(screen.queryByText("Original response")).not.toBeInTheDocument();
+ expect(mockedAttacksApi.submitMessageSend).not.toHaveBeenCalled();
+ await user.click(screen.getByRole("button", { name: "Send message" }));
+ await waitFor(() => expect(mockedAttacksApi.submitMessageSend).toHaveBeenCalledTimes(1));
+ const request = mockedAttacksApi.submitMessageSend.mock.calls[0][1];
+ expect(request.pieces[0]).toMatchObject({
+ original_value: "Original request", converted_value: "Converted request", prompt_metadata: { custom: "preserved" },
+ });
+ expect(request.pieces[1]).toMatchObject({ original_value: "original.png", data_type: "image_path" });
+ expect(mockedConvertersApi.previewConversion).not.toHaveBeenCalled();
+ });
+
+ it("keeps assistant history blocked for a single-turn target", async () => {
+ const { user } = await setupFirstPromptChat();
+ await user.click(screen.getAllByRole("button", { name: "Copy or Branch" })[1]);
+ expect(screen.getByRole("menuitem", { name: "Branch conversation", exact: true })).toHaveAttribute("aria-disabled", "true");
+ expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled();
+ });
+
+ it("rechecks the selected prompt before saving a branch", async () => {
+ const { user, source, stored } = await setupFirstPromptChat();
+ stored.set("first-source", {
+ ...source, conversation_id: "first-source", messages: [{
+ ...source.messages[0], message_pieces: [{ ...source.messages[0].message_pieces[0], id: "changed-piece" }],
+ }, source.messages[1]],
+ });
+ await branchFirstPrompt(user);
+ expect(await screen.findByText(/selected prompt changed/)).toBeInTheDocument();
+ expect(mockedAttacksApi.saveConversation).not.toHaveBeenCalled();
+ expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("");
+ });
+
+ it("does not replace the destination draft if navigation changes during a branch save", async () => {
+ const { user, stored, selected, attack } = await setupFirstPromptChat();
+ let finishSave: ((response: AddMessageResponse) => void) | undefined;
+ mockedAttacksApi.saveConversation.mockImplementationOnce(() => new Promise((resolve) => { finishSave = resolve; }));
+ await branchFirstPrompt(user);
+ await waitFor(() => expect(mockedAttacksApi.saveConversation).toHaveBeenCalledTimes(1));
+ await user.click(screen.getByRole("button", { name: "Switch conversation" }));
+ await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled());
+ await user.type(screen.getByPlaceholderText("Type prompt here"), "Keep this new draft");
+ const other = stored.get("other");
+ if (!other || !finishSave) throw new Error("Expected the pending save and destination conversation");
+ const completeSave = finishSave;
+ await act(async () => { completeSave({ attack, messages: { ...other, conversation_id: "first-branch" } }); });
+ expect(await screen.findByText(/without replacing your current draft/)).toBeInTheDocument();
+ expect(selected).not.toHaveBeenCalled();
+ expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("Keep this new draft");
+ });
+
+ it("preserves the sent conversation on a failed branch and reuses the save ID on retry", async () => {
+ const { user } = await setupFirstPromptChat();
+ mockedAttacksApi.saveConversation.mockRejectedValueOnce(new Error("Connection lost"));
+ await branchFirstPrompt(user);
+ expect(await screen.findByText("Connection lost")).toBeInTheDocument();
+ expect(screen.getByText("Original response")).toBeInTheDocument();
+ expect(screen.getByPlaceholderText("Type prompt here")).toHaveValue("");
+ const saveId = mockedAttacksApi.saveConversation.mock.calls[0][0].save_id;
+ await branchFirstPrompt(user);
+ await waitFor(() => expect(screen.getByPlaceholderText("Type prompt here")).toBeEnabled());
+ expect(mockedAttacksApi.saveConversation.mock.calls[1][0].save_id).toBe(saveId);
+ });
+ });
+
+ it("keeps the composer visible and explains the single-turn restriction on saved chats", async () => {
+ const user = userEvent.setup();
const singleTurnTarget: TargetInstance = makeTarget({
target_registry_name: "openai_image_1",
target_type: "OpenAIImageTarget",
@@ -5530,10 +5779,12 @@ describe("ChatWindow Integration", () => {
);
await waitFor(() => {
- expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
- expect(screen.getByText(/only supports single-turn/)).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeDisabled();
});
- expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ await user.click(screen.getByLabelText("Why the prompt box is disabled"));
+ expect(screen.getByRole("listitem")).toHaveTextContent("This target only supports single-turn conversations.");
+ expect(screen.queryByText(/Wait for the current operation/)).not.toBeInTheDocument();
});
it("should not show single-turn banner for single-turn target with no messages", () => {
@@ -5585,7 +5836,7 @@ describe("ChatWindow Integration", () => {
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
- it("should show New Conversation button in single-turn banner when conversation exists", async () => {
+ it("keeps the composer visible without a single-turn banner when Undo is unavailable", async () => {
const singleTurnTarget: TargetInstance = makeTarget({
target_registry_name: "openai_tts_1",
target_type: "OpenAITTSTarget",
@@ -5613,8 +5864,10 @@ describe("ChatWindow Integration", () => {
);
await waitFor(() => {
- expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeDisabled();
});
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.queryByTestId("new-conversation-btn")).not.toBeInTheDocument();
});
it("should show cross-target banner when attackTarget differs from activeTarget", () => {
@@ -5875,6 +6128,7 @@ describe("ChatWindow Integration", () => {
// -----------------------------------------------------------------------
it("should create a new conversation and select it via handleNewConversation", async () => {
+ const user = userEvent.setup();
const onSelectConversation = jest.fn();
mockedAttacksApi.createConversation.mockResolvedValue({
conversation_id: "new-conv-from-new",
@@ -5907,12 +6161,9 @@ describe("ChatWindow Integration", () => {
);
- // For single-turn targets with existing messages, there's a New Conversation button
- await waitFor(() => {
- expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
- });
-
- await userEvent.click(screen.getByTestId("new-conversation-btn"));
+ await screen.findByText("Generate an image");
+ await user.click(screen.getByRole("button", { name: "Toggle conversations panel" }));
+ await user.click(await screen.findByTestId("new-conversation-btn"));
await waitFor(() => {
expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith("ar-new-conv", {});
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
index 8d2b88740f..059fa72835 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -42,7 +42,7 @@ import type { RegisteredComponentReference, ScoringFailure, ManualContinuation,
import ConversationEditor from './ConversationEditor'
import type { ConversationEditorHandle } from './ConversationEditor'
import {
- attachmentDraft, draftDataTypes, draftMessageToComposer, editorTargetDisabledReason, historyReplayDisabledReason,
+ attachmentDraft, canBranchFirstPrompt, draftDataTypes, draftMessageToComposer, editorTargetDisabledReason, historyReplayDisabledReason,
newDraftPiece, toConversationDraft,
} from '@/utils/conversationDraft'
import { useConversationSave } from '@/hooks/useConversationSave'
@@ -80,16 +80,19 @@ import type {
AttackSummary,
AttackTargetResolutionStatus,
BackendMessage,
+ BackendMessagePiece,
BackendScore,
ChatSendOutcome,
ChatConverterSnapshot,
ConversationMessagesResponse,
ConversationDraftPiece,
+ ConverterInputPiece,
ConverterPipelineStage,
CreateAttackRequest,
CreateConversationRequest,
Message,
MessageAttachment,
+ MessageDisplayPiece,
MessageSendConversation,
MessageSendRequest,
MessageSendStatus,
@@ -1495,10 +1498,11 @@ export default function ChatWindow({
/** 1. Copy the clicked message's content/attachments into the current conversation's input box */
const handleCopyToInput = useCallback((messageIndex: number) => {
+ if (activeTarget?.capabilities?.supports_multi_turn === false && messages.some((message: Message) => message.role === 'user')) return
const msg = messages[messageIndex]
if (!msg) { return }
copyMessageToInput(msg)
- }, [copyMessageToInput, messages])
+ }, [activeTarget, copyMessageToInput, messages])
const handleChangeMainConversation = useCallback(async (convId: string) => {
if (
@@ -1661,6 +1665,32 @@ export default function ChatWindow({
return pending
}
+ const promptRestoreDisabledReason = (snapshot?: ChatConverterSnapshot): string | undefined => {
+ if (converters.isConverting) return 'Wait for the current conversion to finish before restoring a sent prompt.'
+ if (chatInputText || draftAttachments.length || Object.entries(converters.workingInputs).some(
+ ([id, value]: [string, string]) => value !== converters.inputs.find((input: ConverterInputPiece) => input.id === id)?.value,
+ ) || Object.keys(converters.applied).length) {
+ return 'Clear the current prompt and conversion edits before restoring a sent prompt.'
+ }
+ if (snapshot
+ ? JSON.stringify(converters.pipelines) !== JSON.stringify(snapshot.pipelines)
+ : Object.values(converters.pipelines).some((stages: ConverterPipelineStage[]) => stages.length > 0)) {
+ return snapshot
+ ? 'The converter pipeline changed after sending. Restore is unavailable for this prompt.'
+ : 'Clear the current converter pipeline before restoring a stored prompt.'
+ }
+ if (snapshot && Object.keys(snapshot.errors).length > 0) {
+ return 'The previous conversion was incomplete. Restore is unavailable for this prompt.'
+ }
+ return undefined
+ }
+
+ const firstPromptSend = undoableSend?.attackResultId === attackResultId
+ && undoableSend.conversationId === viewedConversationId
+ && undoableSend.converters.generation === runtime.generation
+ && undoableSend.history.every((message: Pick) => message.role === 'system' || message.role === 'developer')
+ ? undoableSend : undefined
+
const copyConversation = async (
messageIndex: number, destination: ConversationCopyDestination,
): Promise => {
@@ -1674,6 +1704,15 @@ export default function ChatWindow({
}
const sourceId = viewedConversationId
const draftRevision = inputBoxRef.current?.getDraftRevision()
+ const navigationRevision = navigationRevisionRef.current
+ const selectedPieceIds = messages[messageIndex]?.displayPieces?.map((piece: MessageDisplayPiece) => piece.pieceId) ?? []
+ const restoresFirstPrompt = destination === 'same_attack' && activeTarget
+ && canBranchFirstPrompt(activeTarget, messages, messageIndex)
+ const draftUnchanged = (): boolean => viewedAttackRef.current === attackResultId
+ && getViewedConversationId() === sourceId
+ && navigationRevisionRef.current === navigationRevision
+ && inputBoxRef.current?.getDraftRevision() === draftRevision
+ && launchStateRef.current.generation === runtime.generation && launchStateRef.current.ready
copyingRef.current = true
setIsLoadingEdit(true)
setEditorError(null)
@@ -1691,6 +1730,29 @@ export default function ChatWindow({
? source.messages.filter((message: BackendMessage) => message.turn_number <= recoverableSend.historyCutoffIndex)
: source.messages.slice(0, messageIndex + 1)
let copiedMessages = toConversationDraft(sourceMessages)
+ let restoredPrompt: ReturnType | undefined
+ let promptSnapshot: ChatConverterSnapshot | undefined
+ let missingConverterSelections = false
+ if (restoresFirstPrompt && activeTarget) {
+ const selected = source.messages[messageIndex]
+ if (!canBranchFirstPrompt(activeTarget, source.messages, messageIndex)
+ || selectedPieceIds.some((id: string) => !selected.message_pieces.some((piece: BackendMessagePiece) => piece.id === id))) {
+ throw new Error('The selected prompt changed. Reload the conversation before branching.')
+ }
+ if (!draftUnchanged()) throw new Error('The view, prompt, or runtime changed. Your current draft was not replaced.')
+ const saved = firstPromptSend?.cutoffIndex === selected.turn_number - 1
+ && firstPromptSend.historySignature === conversationPieceSignature(source) ? firstPromptSend : undefined
+ const restoreError = promptRestoreDisabledReason(saved?.converters)
+ if (restoreError) throw new Error(restoreError)
+ promptSnapshot = saved?.converters ?? {
+ generation: runtime.generation, pipelines: {}, workingInputs: {}, stageResults: {}, errors: {}, applied: {},
+ }
+ restoredPrompt = saved ? {
+ text: saved.originalValue, attachments: saved.attachments, textMetadata: saved.textMetadata, converters: saved.converters,
+ } : draftMessageToComposer(copiedMessages[copiedMessages.length - 1], promptSnapshot)
+ missingConverterSelections = !saved && selected.message_pieces.some((piece: BackendMessagePiece) => Boolean(piece.converter_identifiers?.length))
+ copiedMessages = copiedMessages.slice(0, -1)
+ }
if (isFailedPrompt && activeTarget
&& editorTargetDisabledReason(activeTarget, draftDataTypes(copiedMessages), copiedMessages)) {
copiedMessages = []
@@ -1729,6 +1791,11 @@ export default function ChatWindow({
labels,
messages: copiedMessages,
}, destination === 'same_attack' ? 'same_attack' : 'new_attack')
+ setPanelRefreshKey((key: number) => key + 1)
+ if (restoredPrompt && !draftUnchanged()) {
+ setEditorNotice(`Branch created conversation ${response.messages.conversation_id} without replacing your current draft.`)
+ return
+ }
if (getViewedConversationId() !== sourceId) return
if (destination === 'technique') {
const params = new URLSearchParams({ prepended_conversation_id: response.messages.conversation_id })
@@ -1736,12 +1803,22 @@ export default function ChatWindow({
return
}
if (isFailedPrompt && inputBoxRef.current?.getDraftRevision() !== draftRevision) return
- if (destination === 'same_attack') onSelectConversation(response.messages.conversation_id)
+ if (destination === 'same_attack') {
+ if (restoredPrompt) {
+ setMessages(backendMessagesToFrontend(response.messages.messages))
+ markConversationLoaded(response.messages.conversation_id)
+ }
+ onSelectConversation(response.messages.conversation_id)
+ }
else onConversationCreated(response.attack.attack_result_id, response.messages.conversation_id, response.attack.objective, target)
onAttackChange?.(response.attack)
if (isFailedPrompt) restoreRecoverableDraft(response.messages.conversation_id)
+ if (restoredPrompt && promptSnapshot) {
+ restoreEditorPrompt(restoredPrompt, promptSnapshot)
+ setUndoableSend(null)
+ setRecoveredDraftWarningConversationId(missingConverterSelections ? response.messages.conversation_id : null)
+ }
setIsPanelOpen(true)
- setPanelRefreshKey((key: number) => key + 1)
} catch (error) {
if (getViewedConversationId() === sourceId) setEditorError(toApiError(error).detail)
} finally {
@@ -1779,6 +1856,10 @@ export default function ChatWindow({
&& undoableSend.conversationId === viewedConversationId ? undoableSend.history : messages
const showUndoLastSend = !activeTarget || !historyReplayDisabledReason(activeTarget, undoHistory)
const branchConversationDisabledReason = (index: number): string | undefined => {
+ if (activeTarget && canBranchFirstPrompt(activeTarget, messages, index)
+ && recoverableSend?.failedRequestMessageIndex !== index) {
+ return promptRestoreDisabledReason(firstPromptSend?.converters)
+ }
if (targetResolutionStatus === 'unbound') return undefined
if (!activeTarget) return 'Configure a target before branching this conversation.'
if (recoverableSend?.failedRequestMessageIndex === index) {
@@ -1803,15 +1884,7 @@ export default function ChatWindow({
? 'Open the conversation containing the last send to undo it.'
: activeTarget && historyReplayDisabledReason(activeTarget, undoableSend.history)
? 'This target cannot replay edited conversation history.'
- : (chatInputText || draftAttachments.length || Object.entries(converters.workingInputs).some(
- ([id, value]: [string, string]) => value !== converters.inputs.find((input) => input.id === id)?.value,
- ) || Object.keys(converters.applied).length)
- ? 'Clear the current prompt and conversion edits before undoing the previous send.'
- : JSON.stringify(converters.pipelines) !== JSON.stringify(undoableSend.converters.pipelines)
- ? 'The converter pipeline changed after sending. Undo is unavailable for this turn.'
- : Object.keys(undoableSend.converters.errors).length > 0
- ? 'The previous conversion was incomplete. Undo is unavailable for this turn.'
- : undefined
+ : promptRestoreDisabledReason(undoableSend.converters)
const handleUndoLastSend = async (): Promise => {
if (undoDisabledReason || !undoableSend || undoInFlight.current) return
@@ -1873,13 +1946,19 @@ export default function ChatWindow({
}
const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user')
+ const singleTurnDisabledReason = singleTurnLimitReached ? 'This target only supports single-turn conversations.' : undefined
const hasProcessingError = Boolean(recoverableSend)
const inputDisabledReasons = [
+ ...(singleTurnDisabledReason ? [singleTurnDisabledReason] : []),
...(executionReadOnly ? ['Automated results are read-only. Choose Continue manually.'] : []),
...(isUndoingSend ? ['The previous send is being restored. Wait for undo to finish.'] : []),
...(!runtime.ready ? ['The runtime is not ready. Wait for initialization to finish.'] : []),
...(isSending ? ['A message is being sent. Wait for the response.'] : []),
...(isLoadingAttack ? ['The attack is loading. Wait for it to finish.'] : []),
+ ...(editDraft !== null ? ['Finish editing the conversation before sending.'] : []),
+ ...(isOperatorLocked ? ['This attack belongs to another operator.'] : []),
+ ...(isCrossTargetLocked ? ['This attack uses a different target.'] : []),
+ ...(isTargetResolutionLocked ? ['The target used by this attack could not be resolved.'] : []),
...(isEvidenceUnavailable ? ['This finding evidence is unavailable. Sending is disabled.'] : []),
...(hasProcessingError
? ['This conversation contains a target error. Use Copy or Branch on the failed prompt to branch into a new conversation or attack.']
@@ -2197,12 +2276,16 @@ export default function ChatWindow({
{editDraft === null && { void copyConversation(index, 'same_attack') }}
onCopyToNewAttack={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'new_attack') }}
onUseInTechnique={newAttackDisabledReason ? undefined : (index: number) => { void copyConversation(index, 'technique') }}
copyConversationDisabled={isSending || isLoadingEdit || isRecoveringProcessingError || Boolean(sendIssue?.blocking)}
newConversationDisabledReason={isMutationLocked ? "This attack is read-only. Copy to a new attack instead." : undefined}
getBranchConversationDisabledReason={branchConversationDisabledReason}
+ getBranchConversationDescription={(index: number) => activeTarget && canBranchFirstPrompt(activeTarget, messages, index)
+ ? 'Restore this prompt as an unsent draft in a new conversation in the same attack. The response is not copied.'
+ : undefined}
isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad}
globalMarkdown={globalMarkdown}
onEditConversation={executionReadOnly ? undefined : () => { void beginEdit() }}
@@ -2268,19 +2351,8 @@ export default function ChatWindow({
supportsSystemPrompt={supportsSystemPrompt}
systemPrompt={systemPrompt}
onSystemPromptChange={setSystemPrompt}
- disabled={
- !runtime.ready
- || isSending
- || editDraft !== null
- || isLoadingAttack
- || singleTurnLimitReached
- || isMutationLocked
- || hasProcessingError
- || isUndoingSend
- }
+ disabled={inputDisabledReasons.length > 0}
activeTarget={activeTarget}
- singleTurnLimitReached={singleTurnLimitReached}
- onNewConversation={handleNewConversation}
operatorLocked={isOperatorLocked}
crossTargetLocked={isCrossTargetLocked}
targetResolutionStatus={targetResolutionStatus}
diff --git a/frontend/src/components/Chat/MessageList.test.tsx b/frontend/src/components/Chat/MessageList.test.tsx
index d9e862207a..ff45c8b989 100644
--- a/frontend/src/components/Chat/MessageList.test.tsx
+++ b/frontend/src/components/Chat/MessageList.test.tsx
@@ -90,6 +90,25 @@ describe("MessageList", () => {
expect(screen.getByRole("menuitem", { name: "Branch attack" })).not.toHaveAttribute("aria-disabled", "true");
});
+ it("disables next-message copying without disabling branching", async () => {
+ const user = userEvent.setup();
+ const onCopyToInput = jest.fn();
+ const reason = "This target only supports single-turn conversations.";
+ render();
+ for (const button of screen.getAllByRole("button", { name: "Copy or Branch" })) {
+ await user.click(button);
+ const copy = screen.getByRole("menuitem", { name: "Copy to next message" });
+ expect(copy).toHaveAttribute("aria-disabled", "true");
+ expect(copy).toHaveAccessibleDescription(reason);
+ await user.click(copy);
+ expect(onCopyToInput).not.toHaveBeenCalled();
+ expect(screen.getByRole("menuitem", { name: "Branch conversation" })).not.toHaveAttribute("aria-disabled", "true");
+ await user.keyboard("{Escape}");
+ }
+ });
+
it("should show an accessible description for every copy or branch action and support keyboard selection", async () => {
const user = userEvent.setup();
const onUseInTechnique = jest.fn();
diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx
index 23b672684c..5281361791 100644
--- a/frontend/src/components/Chat/MessageList.tsx
+++ b/frontend/src/components/Chat/MessageList.tsx
@@ -40,12 +40,14 @@ interface MessageListProps {
messages: Message[]
/** Copy this message to the input box of the current conversation */
onCopyToInput?: (messageIndex: number) => void
+ copyToInputDisabledReason?: string
onCopyToNewConversation?: (messageIndex: number) => void
onCopyToNewAttack?: (messageIndex: number) => void
onUseInTechnique?: (messageIndex: number) => void
copyConversationDisabled?: boolean
newConversationDisabledReason?: string
getBranchConversationDisabledReason?: (messageIndex: number) => string | undefined
+ getBranchConversationDescription?: (messageIndex: number) => string | undefined
/** True while loading a historical attack's messages */
isLoading?: boolean
/** Conversation-wide default: render message text as Markdown. */
@@ -436,7 +438,8 @@ function MessageScores({ scores, groupId }: { scores: DisplayScore[]; groupId: s
*/
export default function MessageList({
messages, onCopyToInput, onCopyToNewConversation, onCopyToNewAttack, onUseInTechnique, copyConversationDisabled = false,
- newConversationDisabledReason, getBranchConversationDisabledReason, isLoading, globalMarkdown = false, onEditConversation,
+ newConversationDisabledReason, getBranchConversationDisabledReason, getBranchConversationDescription, copyToInputDisabledReason,
+ isLoading, globalMarkdown = false, onEditConversation,
}: MessageListProps) {
const styles = useMessageListStyles()
const messagesEndRef = useRef(null)
@@ -679,15 +682,17 @@ export default function MessageList({
>Copy or Branch
-