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
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 @@ -151,7 +149,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 @@ -288,17 +286,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 @@ -318,7 +307,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