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
15 changes: 14 additions & 1 deletion frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular, ArrowShuffleRegular, OpenRegular } from '@fluentui/react-icons'
import { MessageAttachment, TargetInstance } from '../../types'
import { useChatInputAreaStyles } from './ChatInputArea.styles'
import SystemPromptSetup from './SystemPromptSetup'
import { PIECE_TYPE_TO_DATA_TYPE } from './converterTypes'

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -329,9 +330,13 @@ interface ChatInputAreaProps {
/** Chip describing a text→file conversion (e.g. PDFConverter output). */
convertedFileChip?: ConvertedFileChip | null
onClearConvertedFileChip?: () => void
/** Whether to show the system-prompt setup (only for a brand-new conversation). */
showSystemPrompt?: boolean
systemPrompt?: string
onSystemPromptChange?: (value: string) => void
}

const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip }, ref) {
const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget, onToggleConverterPanel, isConverterPanelOpen = false, onInputChange, onAttachmentsChange, convertedValue, originalValue: _originalValue, onClearConversion, onConvertedValueChange, converterOutputDataTypes = [], mediaConversions = [], onClearMediaConversion, convertedFileChip, onClearConvertedFileChip, showSystemPrompt = false, systemPrompt = '', onSystemPromptChange }, ref) {
const styles = useChatInputAreaStyles()
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<MessageAttachment[]>([])
Expand Down Expand Up @@ -537,6 +542,14 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
) : (
<>
<div className={styles.inputWrapper}>
{showSystemPrompt && onSystemPromptChange && (
<SystemPromptSetup
value={systemPrompt}
onChange={onSystemPromptChange}
disabled={!!activeTarget && activeTarget.capabilities?.supports_system_prompt !== true}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rich agrees with this comment but it is copilot generated:

The supports_system_prompt rule is encoded twice in two different shapes. Here the editor is gated with capabilities?.supports_system_prompt !== true, while ChatWindow gates the send with the inverse (capabilities?.supports_system_prompt ? buildSystemPrompt(...) : undefined). They agree today, but the source of truth is split between the input and the window. If they ever drift you get a silent failure: an enabled editor whose typed value is dropped on send, or vice-versa. Consider deriving one supportsSystemPrompt boolean in ChatWindow and passing it down so the editor's enabled state and the send-time gate can't disagree.

disabledReason="This target does not support system prompts."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rich agrees with this comment but it is copilot generated:

The editor is disabled whenever supports_system_prompt !== true, which also matches the "capabilities not loaded / unknown" case (capabilities == null). But the reason is always "This target does not support system prompts." If capabilities are merely unresolved, that message asserts something untrue. Consider gating this message on an explicit === false.

/>
)}
<input
ref={fileInputRef}
type="file"
Expand Down
170 changes: 170 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ jest.mock("../../services/api", () => ({
jest.mock("../../utils/messageMapper", () => ({
buildMessagePieces: jest.fn(),
backendMessagesToFrontend: jest.fn(),
buildSystemPrompt: jest.fn(),
}));

const mockedAttacksApi = attacksApi as jest.Mocked<typeof attacksApi>;
Expand Down Expand Up @@ -468,6 +469,175 @@ describe("ChatWindow Integration", () => {
});
});

// -----------------------------------------------------------------------
// System prompt (prepended_conversation) wiring
// -----------------------------------------------------------------------

describe("system prompt", () => {
const supportedTarget: TargetInstance = {
...mockTarget,
capabilities: buildCapabilities({ supports_system_prompt: true }),
};

const sentinelPrepended = [
{ role: "system", pieces: [{ data_type: "text", original_value: "SYS" }] },
];

function primeSendMocks() {
mockedMapper.buildMessagePieces.mockResolvedValue([
{ data_type: "text", original_value: "Hello" },
]);
mockedAttacksApi.createAttack.mockResolvedValue({
attack_result_id: "ar-sys",
conversation_id: "conv-sys",
created_at: "2026-01-01T00:00:00Z",
});
mockedAttacksApi.addMessage.mockResolvedValue(
makeTextResponse("Hi") as never
);
mockedMapper.backendMessagesToFrontend.mockReturnValue([
{ role: "assistant", content: "Hi", timestamp: "2026-01-01T00:00:01Z" },
]);
}

it("renders the system prompt toggle for a new conversation", () => {
render(
<TestWrapper>
<ChatWindow {...defaultProps} activeTarget={supportedTarget} />
</TestWrapper>
);

expect(
screen.getByRole("button", { name: /system prompt/i })
).toBeInTheDocument();
});

it("hides the system prompt toggle once an attack exists", async () => {
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
mockedMapper.backendMessagesToFrontend.mockReturnValue([]);

render(
<TestWrapper>
<ChatWindow
{...defaultProps}
activeTarget={supportedTarget}
attackResultId="ar-existing"
conversationId="conv-existing"
activeConversationId="conv-existing"
/>
</TestWrapper>
);

await waitFor(() => {
expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
});
expect(
screen.queryByRole("button", { name: /system prompt/i })
).not.toBeInTheDocument();
});

it("forwards the built prepended_conversation when the target supports it", async () => {
const user = userEvent.setup();
primeSendMocks();
mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never);

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

await user.type(screen.getByRole("textbox"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedMapper.buildSystemPrompt).toHaveBeenCalled();
expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
expect.objectContaining({ prepended_conversation: sentinelPrepended })
);
});
});

it("never builds a system prompt when the target does not support it", async () => {
const user = userEvent.setup();
primeSendMocks();
mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never);

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

await user.type(screen.getByRole("textbox"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
expect(mockedMapper.buildSystemPrompt).not.toHaveBeenCalled();
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.prepended_conversation).toBeUndefined();
});

it("disables the toggle and drops the prompt for an explicitly unsupported target", async () => {
const user = userEvent.setup();
primeSendMocks();
mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never);

const unsupportedTarget: TargetInstance = {
...mockTarget,
capabilities: buildCapabilities({ supports_system_prompt: false }),
};

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

expect(
screen.getByRole("button", { name: /system prompt/i })
).toBeDisabled();

await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedAttacksApi.createAttack).toHaveBeenCalled();
});
expect(mockedMapper.buildSystemPrompt).not.toHaveBeenCalled();
const createArgs = mockedAttacksApi.createAttack.mock.calls[0][0];
expect(createArgs.prepended_conversation).toBeUndefined();
});

it("passes the operator's typed system prompt through to buildSystemPrompt", async () => {
const user = userEvent.setup();
primeSendMocks();
mockedMapper.buildSystemPrompt.mockReturnValue(sentinelPrepended as never);

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

await user.click(screen.getByRole("button", { name: /system prompt/i }));
await user.type(
screen.getByRole("textbox", { name: /system prompt/i }),
"You are helpful"
);
await user.type(screen.getByPlaceholderText("Type prompt here"), "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));

await waitFor(() => {
expect(mockedMapper.buildSystemPrompt).toHaveBeenCalledWith(
"You are helpful"
);
});
});
});

// -----------------------------------------------------------------------
// Subsequent messages → reuse conversation ID
// -----------------------------------------------------------------------
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import LabelsBar from '../Labels/LabelsBar'
import type { ChatInputAreaHandle } from './ChatInputArea'
import { attacksApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import { buildMessagePieces, backendMessagesToFrontend } from '../../utils/messageMapper'
import { buildMessagePieces, backendMessagesToFrontend, buildSystemPrompt } from '../../utils/messageMapper'
import type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types'
import type { ViewName } from '../Sidebar/Navigation'
import { useChatWindowStyles } from './ChatWindow.styles'
Expand Down Expand Up @@ -70,6 +70,7 @@ export default function ChatWindow({
const [isPanelOpen, setIsPanelOpen] = useState(false)
const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false)
const [chatInputText, setChatInputText] = useState('')
const [systemPrompt, setSystemPrompt] = useState('')
const [attachmentTypes, setAttachmentTypes] = useState<string[]>([])
const [attachmentData, setAttachmentData] = useState<Record<string, string>>({})
const [pieceConversions, setPieceConversions] = useState<Record<string, PieceConversion>>({})
Expand Down Expand Up @@ -142,6 +143,7 @@ export default function ChatWindow({
if (!attackResultId) {
setMessages([])
setLoadedConversationId(null)
setSystemPrompt('')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rich agrees with this comment but it is copilot generated:

systemPrompt is only reset when attackResultId flips to null, not on target change. If a user types a prompt under a supporting target, switches to a non-supporting one, then sends, the text is retained in state but silently discarded (and the editor is collapsed/disabled so they can't see why). Worth considering resetting or surfacing the retained value on target switch.

}
}

Expand Down Expand Up @@ -289,6 +291,9 @@ export default function ChatWindow({
const createResponse = await attacksApi.createAttack({
target_registry_name: activeTarget.target_registry_name,
labels: labels,
prepended_conversation: activeTarget.capabilities?.supports_system_prompt
? buildSystemPrompt(systemPrompt)
: undefined,
})
currentAttackResultId = createResponse.attack_result_id
currentConversationId = createResponse.conversation_id
Expand Down Expand Up @@ -617,6 +622,9 @@ export default function ChatWindow({
<ChatInputArea
ref={inputBoxRef}
onSend={handleSend}
showSystemPrompt={!attackResultId}
systemPrompt={systemPrompt}
onSystemPromptChange={setSystemPrompt}
disabled={isSending || !activeTarget || singleTurnLimitReached || isOperatorLocked || isCrossTargetLocked}
activeTarget={activeTarget}
singleTurnLimitReached={singleTurnLimitReached}
Expand Down
45 changes: 45 additions & 0 deletions frontend/src/components/Chat/SystemPromptSetup.styles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { makeStyles, tokens } from '@fluentui/react-components'

export const useSystemPromptSetupStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXS,
padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalL} 0`,
borderBottom: `1px solid ${tokens.colorNeutralStroke2}`,
},
headerRow: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalS,
},
header: {
color: tokens.colorNeutralForeground2,
},
body: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXS,
paddingBottom: tokens.spacingVerticalS,
},
textareaRoot: {
width: '100%',
},
textareaInner: {
minHeight: '96px',
maxHeight: '30vh',
},
counter: {
alignSelf: 'flex-end',
color: tokens.colorNeutralForeground3,
},
counterOver: {
color: tokens.colorPaletteYellowForeground2,
},
reason: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalXXS,
color: tokens.colorPaletteYellowForeground2,
},
})
Loading
Loading