Skip to content
Merged
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
28 changes: 28 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,34 @@ refreshes do not override your open/closed choice. Creating, copying, or repeati
conversations still opens the desktop drawer. On narrow screens the drawer opens
only when requested and closes after selecting a conversation.

Each saved turn, including a system turn, has a **Copy or Branch** menu with
visible descriptions for each action. **Copy to next message** copies only that message
to the prompt box. **Branch conversation** and **Branch attack** copy history
including that turn into the same attack or a new attack (a separate group of
conversations), open the
conversation panel, and select the copy at its full
`/attacks/{attack_id}/conversations/{conversation_id}` URL. Copying
within a target-free attack keeps it target-free until you send a prompt.
When you save an edited conversation, its final user turn moves to the prompt
box instead of saved history. Text and attachments retain their metadata,
including text-only video remix IDs. You can enter and edit prompts without a target.
Send stays disabled until you select a target; no target-warning box is shown.

**Use in scan** opens the configuration to apply a PyRIT attack technique after
prepending history including the selected turn. It saves a target-free copy
and opens `/scanner/technique.execute?prepended_conversation_id={conversation_id}`.
The scanner configuration fills **prepended_conversation_id** from this URL.
There is no clipboard step, and opening the configuration does not launch a scan.
Select **Add new conversation** beside that field to build simulated history,
or **Edit conversation** to load its current history. **Add to attack** saves all
turns to a new conversation and updates the field. It does not change the source
history, send prompts, or launch a scan. These controls are not in Registry.
Scan launch is disabled while a conversation loads or its editor is open.
The **Apply converters to conversation** toggle beside **Edit conversation** is
off by default and disabled when the conversation ID is empty. When enabled,
request converters run on the stored history during the scan, without changing
the source conversation. New outgoing turns are always converted.

## Dataset catalog

The **Datasets** page shows summaries of datasets already loaded into the backend's
Expand Down
24 changes: 12 additions & 12 deletions frontend/e2e/chat-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -524,9 +524,9 @@ test.describe("Chat processing recovery @seeded", () => {
const bubbles = page.getByTestId(/^message-bubble-/);
await expect(bubbles).toHaveCount(2);
await expect(bubbles.first()).toContainText("Explain the target error");
await expect(bubbles.first().getByRole("button", { name: "Copy conversation", exact: true })).toBeVisible();
await expect(bubbles.first().getByRole("button", { name: "Copy or Branch", exact: true })).toBeVisible();
await expect(bubbles.last()).toContainText("JSONDecodeError");
await expect(bubbles.last().getByRole("button", { name: "Copy conversation", exact: true })).toHaveCount(0);
await expect(bubbles.last().getByRole("button", { name: "Copy or Branch", exact: true })).toHaveCount(0);
await page.reload();
await expect(bubbles).toHaveCount(2);
await expect(bubbles.first()).toContainText("Explain the target error");
Expand All @@ -547,7 +547,7 @@ test.describe("Chat processing recovery @seeded", () => {
contentType: "image/png",
});
await page.keyboard.press("Escape");
await bubbles.first().getByRole("button", { name: "Copy conversation", exact: true }).click();
await bubbles.first().getByRole("button", { name: "Copy or Branch", exact: true }).click();
await test.info().attach("original-prompt-copy", {
body: await page.screenshot({ animations: "disabled" }),
contentType: "image/png",
Expand All @@ -556,7 +556,7 @@ test.describe("Chat processing recovery @seeded", () => {
const [cloneResponse] = await Promise.all([
page.waitForResponse((response) => response.request().method() === "POST"
&& new URL(response.url()).pathname === `/api/attacks/${attackId}/conversations`),
page.getByRole("menuitem", { name: "New conversation", exact: true }).click(),
page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click(),
]);
expect(cloneResponse.status()).toBe(201);
expect(cloneResponse.request().postDataJSON()).toEqual({});
Expand Down Expand Up @@ -612,8 +612,8 @@ test.describe("Chat processing recovery @seeded", () => {
await disabledPrompt.click({ position: { x: 10, y: 10 } });
await expect(page.getByText(/This conversation contains a target error/)).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click();
const recover = page.getByRole("menuitem", { name: "New conversation", exact: true });
await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click();
const recover = page.getByRole("menuitem", { name: "Branch conversation", exact: true });
await expect(recover).toBeEnabled();
await test.info().attach("processing-recovery", {
body: await page.screenshot(),
Expand Down Expand Up @@ -665,11 +665,11 @@ test.describe("Chat processing recovery @seeded", () => {
const failed = await sendFromComposer(page, "Edit this failed prompt");
const sourceAttackId = failed.attack.attack_result_id;
if (reload) await page.reload();
await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click();
await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click();
const [savedResponse] = await Promise.all([
page.waitForResponse((response) => response.request().method() === "POST"
&& new URL(response.url()).pathname === "/api/attacks/save-conversation"),
page.getByRole("menuitem", { name: "New attack", exact: true }).click(),
page.getByRole("menuitem", { name: "Branch attack", exact: true }).click(),
]);
expect(savedResponse.status(), await savedResponse.text()).toBe(200);
const saved: AddMessageResponse = await savedResponse.json();
Expand Down Expand Up @@ -728,11 +728,11 @@ test.describe("Chat processing recovery @seeded", () => {
const originalImage = failed.messages.messages[0].message_pieces[1];
expect(originalImage.original_filename).toBeTruthy();
await page.reload();
await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click();
await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click();
const [createdResponse] = await Promise.all([
page.waitForResponse((response) => response.request().method() === "POST"
&& new URL(response.url()).pathname === "/api/attacks"),
page.getByRole("menuitem", { name: "New attack", exact: true }).click(),
page.getByRole("menuitem", { name: "Branch attack", exact: true }).click(),
]);
expect(createdResponse.status()).toBe(201);
const created: CreateAttackResponse = await createdResponse.json();
Expand Down Expand Up @@ -871,8 +871,8 @@ test.describe("Chat processing recovery @seeded", () => {
await page.getByTestId("remove-attachment-0").click();
await expect(page.getByTestId("clear-media-conversion-image")).toHaveCount(0);
await selectConversation(page, first.attack.conversation_id);
await page.getByRole("button", { name: "Copy conversation", exact: true }).last().click();
const recover = page.getByRole("menuitem", { name: "New conversation", exact: true });
await page.getByRole("button", { name: "Copy or Branch", exact: true }).last().click();
const recover = page.getByRole("menuitem", { name: "Branch conversation", exact: true });
await expect(recover).toBeEnabled();
await page.evaluate(() => { document.documentElement.dataset.deferRecoveryReads = "true"; });
try {
Expand Down
9 changes: 7 additions & 2 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -372,12 +372,17 @@ test.describe("Multiple Messages", () => {
});

test.describe("Chat without target", () => {
test("should disable input when no target is selected", async ({ page }) => {
test("should allow drafting but block sending when no target is selected", async ({ page }) => {
await page.goto("/");
await page.getByTitle("Technique.execute").click();

await expect(page.getByRole("textbox")).toBeDisabled();
const input = page.getByRole("textbox");
await expect(input).toBeEnabled();
await input.fill("Unsent draft");
await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled();
await input.press("Enter");
await expect(input).toHaveValue("Unsent draft");
await expect(page.getByTestId("no-target-banner")).toHaveCount(0);
});
});

Expand Down
7 changes: 4 additions & 3 deletions frontend/e2e/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -710,15 +710,16 @@ test.describe("Target Config ↔ Chat Navigation", () => {
await expect(badge).toContainText(/gpt-4o/);
});

test("should enable chat input after a target is set", async ({ page }) => {
test("should enable sending after a target is set", async ({ page }) => {
await page.route(/\/api\/targets/, async (route) => {
await route.fulfill(mockTargetsList(SAMPLE_TARGETS));
});

// Start in chat with the composer disabled until a target is selected.
await page.goto("/");
await page.getByTitle("Technique.execute").click();
await expect(page.getByRole("textbox")).toBeDisabled();
await expect(page.getByRole("textbox")).toBeEnabled();
await page.getByRole("textbox").fill("Draft without a target");
await expect(page.getByRole("button", { name: "Send message" })).toBeDisabled();

// Go to targets, set a target
await page.getByTitle("Registry").click();
Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/errors.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,8 +352,8 @@ test.describe("Error: accepted send with target processing failure", () => {

await page.getByTestId(/^message-bubble-/)
.filter({ hasText: "Preserve this draft" })
.getByRole("button", { name: "Copy conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "New conversation", exact: true }).click();
.getByRole("button", { name: "Copy or Branch", exact: true }).click();
await page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click();
await expect.poll(() => recoveryRequest).toEqual({
source_conversation_id: MOCK_CONV_ID,
cutoff_index: 1,
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/flows.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -842,7 +842,7 @@ for (const variant of TARGET_VARIANTS) {
}

await page.getByTestId("copy-to-input-btn-1").click();
await page.getByRole("menuitem", { name: "New conversation", exact: true }).click();
await page.getByRole("menuitem", { name: "Branch conversation", exact: true }).click();
} else {
// Single-turn targets disable branch buttons in the UI.
// Branch via the API instead to test the backend operation.
Expand Down
7 changes: 6 additions & 1 deletion frontend/e2e/touch-targets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -726,9 +726,14 @@ test.describe("Mobile touch targets", () => {
await expectNoDocumentOverflow(page);

await page.getByTestId("copy-to-input-btn-1").click();
for (const name of ["This conversation", "New conversation", "New attack"]) {
for (const name of ["Copy to next message", "Branch conversation", "Branch attack", "Use in scan"]) {
await expectMinimumTouchTarget(page.getByRole("menuitem", { name, exact: true }));
}
await expect(page.getByText("Copy only this message to the prompt box.", { exact: true })).toBeVisible();
await expect(page.getByText("Copy history including this turn into the same attack.", { exact: true })).toBeVisible();
await expect(page.getByText("Copy history including this turn into a new attack (i.e., separate group of conversations).", { exact: true })).toBeVisible();
await expect(page.getByText("Apply a PyRIT attack technique after prepending history including this turn.", { exact: true })).toBeVisible();
await expectNoDocumentOverflow(page);
await page.keyboard.press("Escape");

const outcomeButton = page.getByRole("button", {
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1265,7 +1265,8 @@ describe("App", () => {
mockCreatedAttackRender = null;
finishRender();
});
expect(screen.getByTestId("route-location")).toHaveTextContent("/attacks/ar-123");
expect(screen.getByTestId("route-location").textContent).toBe("/attacks/ar-123/conversations/conv-123");
expect(screen.getByTestId("active-conversation-id")).toHaveTextContent("conv-123");
expect(screen.getByRole("button", { name: "Set Conv" })).toBeEnabled();
expect(mockGetAttack).not.toHaveBeenCalled();
});
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -546,11 +546,11 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) {
lastResponseMessagePieceId: null,
status: 'success',
})
// Replace when promoting an empty /chat to its attack url (first message);
// Replace when promoting an empty /chat to its conversation URL (first message);
// push when branching from an existing attack so Back returns to the source.
// Keep sends blocked until the new route exposes the created attack's identity.
startCreatedAttackTransition(() => {
navigate(attackRoutePath(arId), { replace: routeAttackId === null })
navigate(attackConversationRoutePath(arId, convId), { replace: routeAttackId === null })
})
}, [activeTarget, generation, routeAttackId, navigate, startCreatedAttackTransition])

Expand Down
36 changes: 30 additions & 6 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -88,19 +88,21 @@ describe("ChatInputArea", () => {
expect(onToggleConverterPanel).toHaveBeenCalledTimes(1);
});

it("should explain a missing target when the disabled composer is clicked", async () => {
it("should allow drafting without a target and keep Send disabled without a warning box", async () => {
const user = userEvent.setup();
render(
<TestWrapper>
<ChatInputArea {...defaultProps} activeTarget={null} disabled />
<ChatInputArea {...defaultProps} activeTarget={null} sendDisabled />
</TestWrapper>
);

expect(screen.getByRole("textbox")).toBeDisabled();
expect(screen.getByRole("textbox")).toBeEnabled();
expect(getSendButton()).toBeDisabled();
await user.type(screen.getByRole("textbox"), "Unsent draft");
expect(screen.queryByText(/No target is selected/)).not.toBeInTheDocument();
await user.click(screen.getByLabelText("Why the prompt box is disabled"));
expect(screen.getByRole("listitem")).toHaveTextContent("No target is selected.");
expect(screen.queryByLabelText("Why the prompt box is disabled")).not.toBeVisible();
await user.click(getSendButton());
expect(defaultProps.onSend).not.toHaveBeenCalled();
});

it("should show all disabled reasons on hover and keyboard activation", async () => {
Expand All @@ -110,7 +112,7 @@ describe("ChatInputArea", () => {
const composer = screen.getByLabelText("Why the prompt box is disabled");
await user.hover(composer);
expect(await screen.findByText("Prompt input is disabled")).toBeInTheDocument();
expect(screen.getAllByRole("listitem")).toHaveLength(3);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
expect(screen.getByText("This conversation contains a target error.")).toBeInTheDocument();
await user.keyboard("{Escape}");
composer.focus();
Expand Down Expand Up @@ -946,6 +948,28 @@ describe("ChatInputArea", () => {
expect(new Set(withNewCopy.map((attachment: MessageAttachment) => attachment.draftId)).size).toBe(3);
});

it("should retain restored text metadata on edits and retries and clear it with the draft", async () => {
const user = userEvent.setup();
const ref = React.createRef<ChatInputAreaHandle>();
const metadata = { video_id: "existing-video" };
const onSend = jest.fn().mockResolvedValueOnce(retryableOutcome).mockResolvedValue(sentOutcome);
render(<TestWrapper><ChatInputArea ref={ref} {...defaultProps} onSend={onSend} /></TestWrapper>);
React.act(() => { ref.current?.restoreDraft("Change the background", [], metadata); });
await user.type(screen.getByRole("textbox"), " to blue");
expect(ref.current?.getTextMetadata()).toEqual(metadata);
await user.click(getSendButton());
expect(ref.current?.getTextMetadata()).toEqual(metadata);
await user.click(getSendButton());
await waitFor(() => expect(screen.getByRole("textbox")).toHaveValue(""));
expect(ref.current?.getTextMetadata()).toBeUndefined();
React.act(() => { ref.current?.restoreDraft("Another remix", [], metadata); });
React.act(() => { ref.current?.setText("An unrelated copied prompt"); });
expect(ref.current?.getTextMetadata()).toBeUndefined();
React.act(() => { ref.current?.restoreDraft("Another remix", [], metadata); });
await user.clear(screen.getByRole("textbox"));
expect(ref.current?.getTextMetadata()).toBeUndefined();
});

it("should keep uploaded duplicate filenames distinct and preserve remaining identity on removal and send", async () => {
const user = userEvent.setup();
const onAttachmentsChange = jest.fn();
Expand Down
17 changes: 11 additions & 6 deletions frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,9 +97,10 @@ function getUnsupportedConverterOutputTypes(converterOutputDataTypes: string[],
export interface ChatInputAreaHandle {
addAttachment: (attachment: MessageAttachment) => void
setText: (text: string) => void
restoreDraft: (text: string, attachments: MessageAttachment[]) => void
restoreDraft: (text: string, attachments: MessageAttachment[], textMetadata?: Record<string, unknown>) => void
focus: () => void
getDraftRevision: () => number
getTextMetadata: () => Record<string, unknown> | undefined
}

interface ChatInputAreaProps {
Expand Down Expand Up @@ -161,12 +162,10 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
const disabledOverlayRef = useRef<HTMLDivElement>(null)
const inputRef = useRef(input)
const attachmentsRef = useRef(attachments)
const textMetadataRef = useRef<Record<string, unknown> | undefined>(undefined)
const draftRevisionRef = useRef(0)
const previousConversionRevisionKeyRef = useRef(conversionRevisionKey)
const inputDisabledReasons = [...disabledReasons]
if (!activeTarget && disabled) {
inputDisabledReasons.push('No target is selected. Select a target to enter a prompt.')
}
if (disabled && inputDisabledReasons.length === 0) {
inputDisabledReasons.push('The prompt box is temporarily unavailable. Wait for the current operation to finish.')
}
Expand All @@ -181,6 +180,7 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
useLayoutEffect(() => { attachmentsRef.current = attachments; onAttachmentsChange(attachments) }, [attachments, onAttachmentsChange])

const changeText = (text: string): void => {
if (!text) textMetadataRef.current = undefined
inputRef.current = text
draftRevisionRef.current += 1
setInput(text)
Expand All @@ -197,13 +197,18 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
addAttachment: (attachment: MessageAttachment): void => {
changeAttachments([...attachmentsRef.current, withDraftIdentity({ ...attachment, draftId: undefined })])
},
setText: changeText,
restoreDraft: (text: string, draftAttachments: MessageAttachment[]): void => {
setText: (text: string): void => {
textMetadataRef.current = undefined
changeText(text)
},
restoreDraft: (text: string, draftAttachments: MessageAttachment[], textMetadata?: Record<string, unknown>): void => {
changeText(text)
textMetadataRef.current = textMetadata
changeAttachments(draftAttachments.map(withDraftIdentity))
},
focus: (): void => { textareaRef.current?.focus() },
getDraftRevision: (): number => draftRevisionRef.current,
getTextMetadata: (): Record<string, unknown> | undefined => textMetadataRef.current,
}))

const unsupportedAttachmentTypes = getUnsupportedAttachmentTypes(attachments, activeTarget)
Expand Down
Loading
Loading