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
7 changes: 7 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,13 @@ 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.
For single-turn targets or targets that cannot replay the first prompt as history,
**Branch conversation** on that first user turn restores it to the prompt box
without copying the response. After a successful single send, **Undo last send**
also restores the prompt in a new conversation, if the target can replay the
preceding history. Both actions restore the saved prompt and converter results
for text, image, audio, video, and mixed responses. They do not overwrite new
prompt edits or changed converter pipelines.
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.
Expand Down
74 changes: 21 additions & 53 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(<TestWrapper><ChatInputArea {...defaultProps} disabled singleTurnLimitReached
render(<TestWrapper><ChatInputArea {...defaultProps} disabled
disabledReasons={["This target only supports single-turn conversations."]}
undoLastSend={{ isUndoing: false, onUndo }} /></TestWrapper>);
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 () => {
Expand Down Expand Up @@ -934,7 +934,7 @@ describe("ChatInputArea", () => {

expect(
screen.getByText(
/does not track conversation history/
/only supports single-turn conversations/
)
).toBeInTheDocument();
});
Expand Down Expand Up @@ -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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
singleTurnLimitReached={true}
onNewConversation={jest.fn()}
/>
</TestWrapper>
);

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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
singleTurnLimitReached={true}
onNewConversation={onNewConversation}
/>
</TestWrapper>
);

await user.click(screen.getByTestId("new-conversation-btn"));
expect(onNewConversation).toHaveBeenCalledTimes(1);
});

it("should show New Conversation button when singleTurnLimitReached", () => {
render(
<TestWrapper>
<ChatInputArea
{...defaultProps}
singleTurnLimitReached={true}
/>
</TestWrapper>
);

expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
render(<TestWrapper><ChatInputArea {...defaultProps} disabled
disabledReasons={["This target only supports single-turn conversations."]}
undoLastSend={withUndo ? { onUndo: jest.fn(), isUndoing: false, disabledReason: "No saved converter state." } : undefined}
/></TestWrapper>);
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(
<TestWrapper>
<ChatInputArea
{...defaultProps}
singleTurnLimitReached={false}
/>
</TestWrapper>
);
Expand Down
17 changes: 3 additions & 14 deletions frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -120,8 +120,6 @@ interface ChatInputAreaProps {
sendDisabled?: boolean
sendDisabledReasons?: string[]
activeTarget?: TargetInstance | null
singleTurnLimitReached?: boolean
onNewConversation?: () => void
operatorLocked?: boolean
crossTargetLocked?: boolean
targetResolutionStatus?: AttackTargetResolutionStatus
Expand Down Expand Up @@ -154,7 +152,7 @@ interface ChatInputAreaProps {

const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(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,
Expand Down Expand Up @@ -293,17 +291,8 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
text="This attack uses a different target. Continue with your target to keep the conversation."
buttonText="Continue with your target" buttonIcon={<CopyRegular />} onButtonClick={onUseAsTemplate}
testId="cross-target-banner" buttonTestId="use-as-template-btn" buttonClassName={styles.touchTarget} />
) : singleTurnLimitReached && !undoLastSend ? (
<StatusBanner className={styles.statusBanner} textClassName={styles.statusBannerText} icon={<InfoRegular fontSize={18} />}
text="This target only supports single-turn conversations." buttonText="New Conversation" buttonIcon={<AddRegular />}
onButtonClick={onNewConversation} testId="single-turn-banner"
buttonTestId="new-conversation-btn" buttonClassName={styles.touchTarget} />
) : (
<>
{singleTurnLimitReached && <StatusBanner className={styles.statusBanner} textClassName={styles.statusBannerText}
icon={<InfoRegular fontSize={18} />} text="This target only supports single-turn conversations."
buttonText="New Conversation" buttonIcon={<AddRegular />} onButtonClick={onNewConversation}
testId="single-turn-banner" buttonTestId="new-conversation-btn" buttonClassName={styles.touchTarget} />}
<div className={styles.composerTrigger}>
<PromptEditor text={input} attachments={attachments} onInputChange={changeText} onAttachmentsChange={changeAttachments}
disabled={disabled} textareaRef={textareaRef} onKeyDown={handleKeyDown}
Expand All @@ -323,7 +312,7 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
<MultiSendSettings options={effectiveSendOptions} disabled={disabled || sendDisabled}
disabledReason={repeatDisabledReason} onChange={setSendOptions} />
{activeTarget?.capabilities?.supports_multi_turn === false && <Tooltip
content="This target does not track conversation history — each turn is sent independently." relationship="description">
content="This target only supports single-turn conversations." relationship="description">
<span className={styles.singleTurnWarning}><InfoRegular fontSize={18} /></span>
</Tooltip>}
<Tooltip content={sendTooltip} relationship="description">
Expand Down
Loading
Loading