+
+
+
+
{children}
);
};
+ MockMainLayout.displayName = "MockMainLayout";
+ return {
+ __esModule: true,
+ default: MockMainLayout,
+ };
});
jest.mock("./components/Chat/ChatWindow", () => {
- return function MockChatWindow({
+ const MockChatWindow = ({
messages,
onSendMessage,
onReceiveMessage,
- onNewChat,
+ onNewAttack,
+ activeTarget,
+ conversationId,
+ onConversationCreated,
+ onSelectConversation,
}: {
- messages: Array<{ id: string; content: string }>;
+ messages: Array<{ id: string; content: string; isLoading?: boolean }>;
onSendMessage: (msg: { id: string; content: string }) => void;
- onReceiveMessage: (msg: { id: string; content: string }) => void;
- onNewChat: () => void;
- }) {
+ onReceiveMessage: (msg: { id: string; content: string; isLoading?: boolean }) => void;
+ onNewAttack: () => void;
+ activeTarget: unknown;
+ conversationId: string | null;
+ onConversationCreated: (attackResultId: string, conversationId: string) => void;
+ onSelectConversation: (convId: string) => void;
+ }) => {
return (
{messages.length}
+ {conversationId ?? "none"}
+ {activeTarget ? "yes" : "no"}
-
+ );
+ };
+ MockChatWindow.displayName = "MockChatWindow";
+ return {
+ __esModule: true,
+ default: MockChatWindow,
+ };
+});
+
+jest.mock("./components/Config/TargetConfig", () => {
+ const MockTargetConfig = ({
+ activeTarget,
+ onSetActiveTarget,
+ }: {
+ activeTarget: unknown;
+ onSetActiveTarget: (t: unknown) => void;
+ }) => {
+ return (
+
+
+ {(activeTarget as { target_registry_name?: string })?.target_registry_name ?? "none"}
+
+
+ onSetActiveTarget({
+ target_id: "t1",
+ target_registry_name: "test_target",
+ target_type: "OpenAIChatTarget",
+ status: "active",
+ })
+ }
+ data-testid="set-target"
+ >
+ Set Target
+
+
+ );
+ };
+ MockTargetConfig.displayName = "MockTargetConfig";
+ return {
+ __esModule: true,
+ default: MockTargetConfig,
+ };
+});
+
+jest.mock("./components/History/AttackHistory", () => {
+ const MockAttackHistory = ({
+ onOpenAttack,
+ }: {
+ onOpenAttack: (attackResultId: string) => void;
+ }) => {
+ return (
+
+ onOpenAttack("ar-attack-1")}
+ data-testid="open-attack"
+ >
+ Open Attack
);
};
+ MockAttackHistory.displayName = "MockAttackHistory";
+ return {
+ __esModule: true,
+ default: MockAttackHistory,
+ };
});
describe("App", () => {
@@ -81,20 +219,17 @@ describe("App", () => {
it("toggles theme when onToggleTheme is called", () => {
render(
);
- // Initially dark mode
expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-dark-mode",
"true"
);
- // Toggle to light mode
fireEvent.click(screen.getByTestId("toggle-theme"));
expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-dark-mode",
"false"
);
- // Toggle back to dark mode
fireEvent.click(screen.getByTestId("toggle-theme"));
expect(screen.getByTestId("main-layout")).toHaveAttribute(
"data-dark-mode",
@@ -121,16 +256,190 @@ describe("App", () => {
expect(screen.getByTestId("message-count")).toHaveTextContent("1");
});
- it("clears messages when handleNewChat is called", () => {
+ it("clears messages when handleNewAttack is called", () => {
render(
);
- // Add some messages first
fireEvent.click(screen.getByTestId("send-message"));
fireEvent.click(screen.getByTestId("receive-message"));
expect(screen.getByTestId("message-count")).toHaveTextContent("2");
- // Clear messages
- fireEvent.click(screen.getByTestId("new-chat"));
+ fireEvent.click(screen.getByTestId("new-attack"));
expect(screen.getByTestId("message-count")).toHaveTextContent("0");
});
+
+ it("starts in chat view", () => {
+ render(
);
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "chat"
+ );
+ expect(screen.getByTestId("chat-window")).toBeInTheDocument();
+ });
+
+ it("switches to config view", () => {
+ render(
);
+
+ fireEvent.click(screen.getByTestId("nav-config"));
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "config"
+ );
+ expect(screen.getByTestId("target-config")).toBeInTheDocument();
+ });
+
+ it("switches back to chat from config", () => {
+ render(
);
+
+ fireEvent.click(screen.getByTestId("nav-config"));
+ expect(screen.getByTestId("target-config")).toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("nav-chat"));
+ expect(screen.getByTestId("chat-window")).toBeInTheDocument();
+ });
+
+ it("sets conversationId from chat window", () => {
+ render(
);
+
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("none");
+
+ fireEvent.click(screen.getByTestId("set-conversation"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
+ });
+
+ it("clears conversationId on new attack", () => {
+ render(
);
+
+ fireEvent.click(screen.getByTestId("set-conversation"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
+
+ fireEvent.click(screen.getByTestId("new-attack"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("none");
+ });
+
+ it("sets active target from config page and passes to chat", () => {
+ render(
);
+
+ // No target initially
+ expect(screen.getByTestId("has-target")).toHaveTextContent("no");
+
+ // Switch to config and set target
+ fireEvent.click(screen.getByTestId("nav-config"));
+ fireEvent.click(screen.getByTestId("set-target"));
+
+ // Switch back to chat — target should be present
+ fireEvent.click(screen.getByTestId("nav-chat"));
+ expect(screen.getByTestId("has-target")).toHaveTextContent("yes");
+ });
+
+ it("switches to history view", () => {
+ render(
);
+
+ fireEvent.click(screen.getByTestId("nav-history"));
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "history"
+ );
+ expect(screen.getByTestId("attack-history")).toBeInTheDocument();
+ });
+
+ it("opens attack from history and switches to chat", async () => {
+ mockGetAttack.mockResolvedValue({ attack_result_id: "ar-attack-1", conversation_id: "attack-conv-1", labels: { operator: "roakey" } });
+ render(
);
+
+ fireEvent.click(screen.getByTestId("nav-history"));
+ fireEvent.click(screen.getByTestId("open-attack"));
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "chat"
+ );
+ await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-attack-1"));
+ await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("attack-conv-1"));
+ });
+
+ it("handles failed attack open gracefully", async () => {
+ mockGetAttack.mockRejectedValue(new Error("Not found"));
+ render(
);
+
+ fireEvent.click(screen.getByTestId("nav-history"));
+ fireEvent.click(screen.getByTestId("open-attack"));
+
+ // Should switch to chat view even on error
+ expect(screen.getByTestId("main-layout")).toHaveAttribute("data-current-view", "chat");
+ await waitFor(() => expect(mockGetAttack).toHaveBeenCalledWith("ar-attack-1"));
+ // Conversation should be cleared on error
+ await waitFor(() => expect(screen.getByTestId("conversation-id")).toHaveTextContent("none"));
+ });
+
+ it("replaces loading indicator with received message", () => {
+ render(
);
+
+ // Send a message, then a loading indicator, then a real response
+ fireEvent.click(screen.getByTestId("send-message"));
+ expect(screen.getByTestId("message-count")).toHaveTextContent("1");
+
+ // Receive replaces loading messages — we can't simulate isLoading from mock,
+ // but we verify the receive handler adds to messages
+ fireEvent.click(screen.getByTestId("receive-message"));
+ expect(screen.getByTestId("message-count")).toHaveTextContent("2");
+ });
+
+ it("replaces loading message when response arrives", () => {
+ render(
);
+
+ // Send a user message
+ fireEvent.click(screen.getByTestId("send-message"));
+ expect(screen.getByTestId("message-count")).toHaveTextContent("1");
+
+ // Receive a loading indicator
+ fireEvent.click(screen.getByTestId("receive-loading"));
+ expect(screen.getByTestId("message-count")).toHaveTextContent("2");
+
+ // Replace loading with actual response
+ fireEvent.click(screen.getByTestId("receive-message"));
+ // Loading message should be replaced, not appended
+ expect(screen.getByTestId("message-count")).toHaveTextContent("2");
+ });
+
+ it("merges default labels from backend version API", async () => {
+ mockedVersionApi.getVersion.mockResolvedValueOnce({
+ version: "2.0.0",
+ default_labels: { operator: "default_user", custom: "value" },
+ });
+
+ render(
);
+
+ // The version API is called on mount and labels get merged
+ await waitFor(() => {
+ expect(mockedVersionApi.getVersion).toHaveBeenCalled();
+ });
+ });
+
+ it("stores attack target when conversation is created with active target", () => {
+ render(
);
+
+ // Set a target first
+ fireEvent.click(screen.getByTestId("nav-config"));
+ fireEvent.click(screen.getByTestId("set-target"));
+ fireEvent.click(screen.getByTestId("nav-chat"));
+
+ // Create a conversation (which should store target info)
+ fireEvent.click(screen.getByTestId("set-conversation"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
+ });
+
+ it("sets active conversation when onSelectConversation is called", () => {
+ render(
);
+
+ // First create a conversation to have an attack
+ fireEvent.click(screen.getByTestId("set-conversation"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
+
+ // Now select a different conversation
+ fireEvent.click(screen.getByTestId("select-conversation"));
+ // The component re-renders with the new conversation ID
+ });
});
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f7f7e13f1e..cade2ced3e 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,43 +1,215 @@
-import { useState } from 'react'
+import { useState, useCallback, useEffect } from 'react'
import { FluentProvider, webLightTheme, webDarkTheme } from '@fluentui/react-components'
import MainLayout from './components/Layout/MainLayout'
import ChatWindow from './components/Chat/ChatWindow'
-import { Message } from './types'
+import TargetConfig from './components/Config/TargetConfig'
+import AttackHistory from './components/History/AttackHistory'
+import { DEFAULT_HISTORY_FILTERS } from './components/History/historyFilters'
+import type { HistoryFilters } from './components/History/historyFilters'
+import { ConnectionBanner } from './components/ConnectionBanner'
+import { ErrorBoundary } from './components/ErrorBoundary'
+import { ConnectionHealthProvider, useConnectionHealth } from './hooks/useConnectionHealth'
+import { DEFAULT_GLOBAL_LABELS } from './components/Labels/labelDefaults'
+import type { ViewName } from './components/Sidebar/Navigation'
+import type { Message, TargetInstance, TargetInfo } from './types'
+import { attacksApi, versionApi } from './services/api'
+
+const AUTO_DISMISS_MS = 5_000
+
+function ConnectionBannerContainer() {
+ const { status, reconnectCount } = useConnectionHealth()
+ const [showReconnected, setShowReconnected] = useState(false)
+
+ useEffect(() => {
+ if (reconnectCount > 0) {
+ setShowReconnected(true)
+ const timer = setTimeout(() => setShowReconnected(false), AUTO_DISMISS_MS)
+ return () => clearTimeout(timer)
+ }
+ }, [reconnectCount])
+
+ if (status === 'connected' && !showReconnected) {
+ return null
+ }
+
+ return
+}
function App() {
const [messages, setMessages] = useState
([])
const [isDarkMode, setIsDarkMode] = useState(true)
+ const [currentView, setCurrentView] = useState('chat')
+ const [activeTarget, setActiveTarget] = useState(null)
+ const [globalLabels, setGlobalLabels] = useState>({ ...DEFAULT_GLOBAL_LABELS })
+ /** True while loading a historical attack from the history view */
+ const [isLoadingAttack, setIsLoadingAttack] = useState(false)
+ /** Persisted filter state for the history view */
+ const [historyFilters, setHistoryFilters] = useState({ ...DEFAULT_HISTORY_FILTERS })
+
+ // Fetch default labels from backend configuration on startup
+ useEffect(() => {
+ versionApi.getVersion()
+ .then((data) => {
+ if (data.default_labels && Object.keys(data.default_labels).length > 0) {
+ setGlobalLabels(prev => ({ ...prev, ...data.default_labels }))
+ }
+ })
+ .catch(() => { /* version fetch handled elsewhere */ })
+ }, [])
+
+ // When the user switches to a genuinely different target, start a fresh attack.
+ // Just re-selecting the same target (or viewing config without changing) keeps
+ // the current conversation intact so the user can branch from it.
+ const handleSetActiveTarget = useCallback((target: TargetInstance) => {
+ setActiveTarget(prev => {
+ const isSame = prev &&
+ prev.target_registry_name === target.target_registry_name &&
+ prev.target_type === target.target_type &&
+ (prev.endpoint ?? '') === (target.endpoint ?? '') &&
+ (prev.model_name ?? '') === (target.model_name ?? '')
+ if (isSame) return prev
+ // Switching targets no longer clears the loaded attack. The cross-target
+ // guard in ChatWindow prevents sending to a mismatched target, and the
+ // backend enforces this server-side as well. Clearing state here was
+ // confusing because navigating to config to pick the *correct* target
+ // would wipe the conversation the user was trying to continue.
+ return target
+ })
+ }, [])
+ /** The AttackResult's primary key (set on first message). */
+ const [attackResultId, setAttackResultId] = useState(null)
+ /** The attack's primary conversation_id (set on first message). */
+ const [conversationId, setConversationId] = useState(null)
+ /** The currently active conversation (may be main or a related conversation). */
+ const [activeConversationId, setActiveConversationId] = useState(null)
+ /** Labels that the currently loaded attack was created with (for operator locking). */
+ const [attackLabels, setAttackLabels] = useState | null>(null)
+ /** Target info from the currently loaded historical attack (for cross-target guard). */
+ const [attackTarget, setAttackTarget] = useState(null)
+ /** Number of related conversations for the currently loaded attack. */
+ const [relatedConversationCount, setRelatedConversationCount] = useState(0)
const handleSendMessage = (message: Message) => {
setMessages(prev => [...prev, message])
}
const handleReceiveMessage = (message: Message) => {
- setMessages(prev => [...prev, message])
+ setMessages(prev => {
+ // If the last message is a loading indicator, replace it
+ if (prev.length > 0 && prev[prev.length - 1].isLoading) {
+ return [...prev.slice(0, -1), message]
+ }
+ return [...prev, message]
+ })
}
- const handleNewChat = () => {
+ const clearAttackState = useCallback(() => {
setMessages([])
+ setAttackResultId(null)
+ setConversationId(null)
+ setActiveConversationId(null)
+ setAttackLabels(null)
+ setAttackTarget(null)
+ setRelatedConversationCount(0)
+ }, [])
+
+ const handleNewAttack = () => {
+ clearAttackState()
}
+ const handleConversationCreated = useCallback((arId: string, convId: string) => {
+ setAttackResultId(arId)
+ setConversationId(convId)
+ setActiveConversationId(convId)
+ // New attack was created by the current user — use their global labels
+ setAttackLabels(null)
+ // Record the target used for this attack so the cross-target guard
+ // fires if the user switches targets mid-conversation.
+ if (activeTarget) {
+ const { target_type, endpoint, model_name } = activeTarget
+ setAttackTarget({ target_type, endpoint, model_name })
+ }
+ }, [activeTarget])
+
+ const handleSelectConversation = useCallback((convId: string) => {
+ setActiveConversationId(convId)
+ // Messages will be loaded by ChatWindow's useEffect
+ }, [])
+
+ const handleOpenAttack = useCallback(async (openAttackResultId: string) => {
+ setMessages([])
+ setAttackResultId(openAttackResultId)
+ setIsLoadingAttack(true)
+ setCurrentView('chat')
+ // Fetch attack info to get conversation_id and stored labels (for operator locking)
+ try {
+ const attack = await attacksApi.getAttack(openAttackResultId)
+ setConversationId(attack.conversation_id)
+ setActiveConversationId(attack.conversation_id)
+ setAttackLabels(attack.labels ?? {})
+ setAttackTarget(attack.target ?? null)
+ setRelatedConversationCount(attack.related_conversation_ids?.length ?? 0)
+ } catch {
+ clearAttackState()
+ } finally {
+ setIsLoadingAttack(false)
+ }
+ }, [clearAttackState])
+
const toggleTheme = () => {
setIsDarkMode(!isDarkMode)
}
return (
-
-
-
-
-
+
+
+
+
+
+ {currentView === 'chat' && (
+
+ )}
+ {currentView === 'config' && (
+
+ )}
+ {currentView === 'history' && (
+
+ )}
+
+
+
+
)
}
diff --git a/frontend/src/components/Chat/InputBox.test.tsx b/frontend/src/components/Chat/ChatInputArea.test.tsx
similarity index 62%
rename from frontend/src/components/Chat/InputBox.test.tsx
rename to frontend/src/components/Chat/ChatInputArea.test.tsx
index 17b28910e4..c2712acddf 100644
--- a/frontend/src/components/Chat/InputBox.test.tsx
+++ b/frontend/src/components/Chat/ChatInputArea.test.tsx
@@ -1,7 +1,9 @@
+import React from "react";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
-import InputBox from "./InputBox";
+import ChatInputArea from "./ChatInputArea";
+import type { ChatInputAreaHandle } from "./ChatInputArea";
// Wrapper component for Fluent UI context
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
@@ -11,7 +13,7 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
// Helper to get the send button specifically
const getSendButton = () => screen.getByRole("button", { name: /send/i });
-describe("InputBox", () => {
+describe("ChatInputArea", () => {
const defaultProps = {
onSend: jest.fn(),
disabled: false,
@@ -24,7 +26,7 @@ describe("InputBox", () => {
it("should render input area and send button", () => {
render(
-
+
);
@@ -38,7 +40,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -52,7 +54,7 @@ describe("InputBox", () => {
it("should disable input when disabled prop is true", () => {
render(
-
+
);
@@ -63,7 +65,7 @@ describe("InputBox", () => {
it("should disable send button when input is empty", () => {
render(
-
+
);
@@ -76,7 +78,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -93,7 +95,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -112,7 +114,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -129,7 +131,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -146,7 +148,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -162,7 +164,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -173,7 +175,7 @@ describe("InputBox", () => {
it("should have file input for attachments", () => {
render(
-
+
);
@@ -186,7 +188,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -210,7 +212,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -231,7 +233,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -264,7 +266,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -292,7 +294,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -313,7 +315,7 @@ describe("InputBox", () => {
render(
-
+
);
@@ -329,12 +331,64 @@ describe("InputBox", () => {
});
});
+ it("should show single-turn warning when target does not support multiturn chat", () => {
+ render(
+
+
+
+ );
+
+ expect(
+ screen.getByText(
+ /does not track conversation history/
+ )
+ ).toBeInTheDocument();
+ });
+
+ it("should not show single-turn warning when target supports multiturn chat", () => {
+ render(
+
+
+
+ );
+
+ expect(
+ screen.queryByText(/does not track conversation history/)
+ ).not.toBeInTheDocument();
+ });
+
+ it("should not show single-turn warning when no active target", () => {
+ render(
+
+
+
+ );
+
+ expect(
+ screen.queryByText(/does not track conversation history/)
+ ).not.toBeInTheDocument();
+ });
+
it("should handle multiple file attachments", async () => {
const user = userEvent.setup();
render(
-
+
);
@@ -355,4 +409,96 @@ describe("InputBox", () => {
expect(screen.getByText(/audio\.mp3/)).toBeInTheDocument();
});
});
+
+ it("should show attachment chip when addAttachment is called via ref", async () => {
+ const ref = React.createRef();
+
+ render(
+
+
+
+ );
+
+ // Programmatically add an attachment via the ref
+ React.act(() => {
+ ref.current?.addAttachment({
+ type: "image",
+ name: "forwarded.png",
+ url: "data:image/png;base64,abc=",
+ mimeType: "image/png",
+ size: 512,
+ });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText(/forwarded\.png/)).toBeInTheDocument();
+ });
+
+ // Send button should be enabled since there's an attachment
+ expect(screen.getByTitle("Send message")).toBeEnabled();
+ });
+
+ 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 () => {
+ const user = userEvent.setup();
+ const onNewConversation = jest.fn();
+
+ render(
+
+
+
+ );
+
+ await user.click(screen.getByTestId("new-conversation-btn"));
+ expect(onNewConversation).toHaveBeenCalledTimes(1);
+ });
+
+ it("should not show New Conversation button when onNewConversation is not provided", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
+ expect(screen.queryByTestId("new-conversation-btn")).not.toBeInTheDocument();
+ });
+
+ it("should show normal input when singleTurnLimitReached is false", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
});
diff --git a/frontend/src/components/Chat/ChatInputArea.tsx b/frontend/src/components/Chat/ChatInputArea.tsx
new file mode 100644
index 0000000000..d94275d14c
--- /dev/null
+++ b/frontend/src/components/Chat/ChatInputArea.tsx
@@ -0,0 +1,489 @@
+import { useState, useEffect, useRef, forwardRef, useImperativeHandle, KeyboardEvent } from 'react'
+import {
+ makeStyles,
+ Button,
+ tokens,
+ Caption1,
+ Tooltip,
+ Text,
+} from '@fluentui/react-components'
+import { SendRegular, AttachRegular, DismissRegular, InfoRegular, AddRegular, CopyRegular, WarningRegular, SettingsRegular } from '@fluentui/react-icons'
+import { MessageAttachment, TargetInstance } from '../../types'
+
+const useStyles = makeStyles({
+ root: {
+ padding: `${tokens.spacingVerticalXL} ${tokens.spacingHorizontalXXL}`,
+ backgroundColor: tokens.colorNeutralBackground2,
+ },
+ inputContainer: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: tokens.spacingVerticalM,
+ maxWidth: '900px',
+ margin: '0 auto',
+ },
+ attachmentsContainer: {
+ display: 'flex',
+ flexWrap: 'wrap',
+ gap: tokens.spacingHorizontalS,
+ paddingLeft: tokens.spacingHorizontalL,
+ paddingRight: tokens.spacingHorizontalL,
+ paddingTop: tokens.spacingVerticalS,
+ },
+ attachmentChip: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalXXS,
+ padding: `${tokens.spacingVerticalXXS} ${tokens.spacingHorizontalS}`,
+ backgroundColor: tokens.colorNeutralBackground4,
+ borderRadius: tokens.borderRadiusLarge,
+ },
+ inputWrapper: {
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ backgroundColor: tokens.colorNeutralBackground3,
+ borderRadius: '28px',
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ transition: 'border-color 0.2s ease, box-shadow 0.2s ease',
+ ':focus-within': {
+ borderTopColor: tokens.colorBrandStroke1,
+ borderRightColor: tokens.colorBrandStroke1,
+ borderBottomColor: tokens.colorBrandStroke1,
+ borderLeftColor: tokens.colorBrandStroke1,
+ boxShadow: `0 0 0 2px ${tokens.colorBrandBackground2}`,
+ },
+ },
+ inputRow: {
+ display: 'flex',
+ alignItems: 'center',
+ padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
+ },
+ textInput: {
+ flex: 1,
+ backgroundColor: 'transparent',
+ border: 'none',
+ outline: 'none',
+ fontSize: tokens.fontSizeBase300,
+ fontFamily: tokens.fontFamilyBase,
+ color: tokens.colorNeutralForeground1,
+ resize: 'none',
+ minHeight: '24px',
+ maxHeight: '96px',
+ overflowY: 'auto',
+ '::placeholder': {
+ color: tokens.colorNeutralForeground4,
+ },
+ '::-webkit-scrollbar': {
+ width: '8px',
+ },
+ '::-webkit-scrollbar-track': {
+ backgroundColor: 'transparent',
+ },
+ '::-webkit-scrollbar-thumb': {
+ backgroundColor: tokens.colorNeutralStroke1,
+ borderRadius: '4px',
+ },
+ },
+ iconButtonsLeft: {
+ display: 'flex',
+ gap: tokens.spacingHorizontalXS,
+ marginRight: tokens.spacingHorizontalS,
+ },
+ iconButtonsRight: {
+ display: 'flex',
+ gap: tokens.spacingHorizontalXS,
+ marginLeft: tokens.spacingHorizontalS,
+ },
+ iconButton: {
+ minWidth: '32px',
+ width: '32px',
+ height: '32px',
+ padding: 0,
+ borderRadius: '50%',
+ },
+ sendButton: {
+ minWidth: '32px',
+ width: '32px',
+ height: '32px',
+ padding: 0,
+ borderRadius: '50%',
+ },
+ singleTurnWarning: {
+ display: 'flex',
+ alignItems: 'center',
+ color: tokens.colorPaletteYellowForeground2,
+ },
+ singleTurnBanner: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: tokens.spacingHorizontalM,
+ padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
+ backgroundColor: tokens.colorNeutralBackground3,
+ borderRadius: '28px',
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ },
+ singleTurnText: {
+ color: tokens.colorNeutralForeground2,
+ },
+ noTargetBanner: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: tokens.spacingHorizontalM,
+ padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
+ backgroundColor: tokens.colorPaletteRedBackground1,
+ borderRadius: '28px',
+ border: `1px solid ${tokens.colorPaletteRedBorder1}`,
+ },
+ noTargetText: {
+ color: tokens.colorPaletteRedForeground1,
+ fontWeight: tokens.fontWeightSemibold as unknown as string,
+ },
+})
+
+// ---------------------------------------------------------------------------
+// Banner sub-components
+// ---------------------------------------------------------------------------
+
+interface NoTargetBannerProps {
+ className: string
+ textClassName: string
+ onConfigureTarget?: () => void
+}
+
+function NoTargetBanner({ className, textClassName, onConfigureTarget }: NoTargetBannerProps) {
+ return (
+
+
+
+ No target selected
+
+ {onConfigureTarget && (
+ }
+ onClick={onConfigureTarget}
+ data-testid="configure-target-input-btn"
+ >
+ Configure Target
+
+ )}
+
+ )
+}
+
+interface OperatorLockedBannerProps {
+ className: string
+ textClassName: string
+ attackOperator?: string
+ onUseAsTemplate?: () => void
+}
+
+function OperatorLockedBanner({ className, textClassName, attackOperator, onUseAsTemplate }: OperatorLockedBannerProps) {
+ return (
+
+
+
+ This conversation belongs to operator: {attackOperator}.
+
+ {onUseAsTemplate && (
+ }
+ onClick={onUseAsTemplate}
+ data-testid="use-as-template-btn"
+ >
+ Continue with your target
+
+ )}
+
+ )
+}
+
+interface CrossTargetBannerProps {
+ className: string
+ textClassName: string
+ onUseAsTemplate?: () => void
+}
+
+function CrossTargetBanner({ className, textClassName, onUseAsTemplate }: CrossTargetBannerProps) {
+ return (
+
+
+
+ This attack uses a different target. Continue with your target to keep the conversation.
+
+ {onUseAsTemplate && (
+ }
+ onClick={onUseAsTemplate}
+ data-testid="use-as-template-btn"
+ >
+ Continue with your target
+
+ )}
+
+ )
+}
+
+interface SingleTurnBannerProps {
+ className: string
+ textClassName: string
+ onNewConversation?: () => void
+}
+
+function SingleTurnBanner({ className, textClassName, onNewConversation }: SingleTurnBannerProps) {
+ return (
+
+
+
+ This target only supports single-turn conversations.
+
+ {onNewConversation && (
+ }
+ onClick={onNewConversation}
+ data-testid="new-conversation-btn"
+ >
+ New Conversation
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Main component
+// ---------------------------------------------------------------------------
+
+export interface ChatInputAreaHandle {
+ addAttachment: (att: MessageAttachment) => void
+ setText: (text: string) => void
+}
+
+interface ChatInputAreaProps {
+ onSend: (originalValue: string, convertedValue: string | undefined, attachments: MessageAttachment[]) => void
+ disabled?: boolean
+ activeTarget?: TargetInstance | null
+ singleTurnLimitReached?: boolean
+ onNewConversation?: () => void
+ operatorLocked?: boolean
+ crossTargetLocked?: boolean
+ onUseAsTemplate?: () => void
+ attackOperator?: string
+ noTargetSelected?: boolean
+ onConfigureTarget?: () => void
+}
+
+const ChatInputArea = forwardRef(function ChatInputArea({ onSend, disabled = false, activeTarget, singleTurnLimitReached = false, onNewConversation, operatorLocked = false, crossTargetLocked = false, onUseAsTemplate, attackOperator, noTargetSelected = false, onConfigureTarget }, ref) {
+ const styles = useStyles()
+ const [input, setInput] = useState('')
+ const [attachments, setAttachments] = useState([])
+ const fileInputRef = useRef(null)
+ const textareaRef = useRef(null)
+
+ useImperativeHandle(ref, () => ({
+ addAttachment: (att: MessageAttachment) => {
+ setAttachments(prev => [...prev, att])
+ },
+ setText: (text: string) => {
+ setInput(text)
+ },
+ }))
+
+ const handleFileSelect = async (e: React.ChangeEvent) => {
+ const files = e.target.files
+ if (!files) return
+
+ const newAttachments: MessageAttachment[] = []
+
+ for (let i = 0; i < files.length; i++) {
+ const file = files[i]
+ const url = URL.createObjectURL(file)
+
+ let type: MessageAttachment['type'] = 'file'
+ if (file.type.startsWith('image/')) type = 'image'
+ else if (file.type.startsWith('audio/')) type = 'audio'
+ else if (file.type.startsWith('video/')) type = 'video'
+
+ newAttachments.push({
+ type,
+ name: file.name,
+ url,
+ mimeType: file.type,
+ size: file.size,
+ file,
+ })
+ }
+
+ setAttachments([...attachments, ...newAttachments])
+ if (fileInputRef.current) {
+ fileInputRef.current.value = ''
+ }
+ }
+
+ const removeAttachment = (index: number) => {
+ const newAttachments = [...attachments]
+ URL.revokeObjectURL(newAttachments[index].url)
+ newAttachments.splice(index, 1)
+ setAttachments(newAttachments)
+ }
+
+ const handleSend = () => {
+ if ((input || attachments.length > 0) && !disabled) {
+ onSend(input, undefined, attachments)
+ setInput('')
+ setAttachments([])
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ }
+ }
+ }
+
+ // Re-focus the textarea after sending completes (disabled goes false)
+ useEffect(() => {
+ if (!disabled && textareaRef.current) {
+ textareaRef.current.focus()
+ }
+ }, [disabled])
+
+ const handleKeyDown = (e: KeyboardEvent) => {
+ if (e.key === 'Enter' && !e.shiftKey) {
+ e.preventDefault()
+ handleSend()
+ }
+ }
+
+ // Auto-resize textarea whenever input changes (covers paste, setText, etc.)
+ useEffect(() => {
+ if (textareaRef.current) {
+ textareaRef.current.style.height = 'auto'
+ textareaRef.current.style.height = Math.min(textareaRef.current.scrollHeight, 96) + 'px'
+ }
+ }, [input])
+
+ const handleInput = (e: React.ChangeEvent) => {
+ setInput(e.target.value)
+ }
+
+ const formatFileSize = (bytes: number) => {
+ if (bytes < 1024) return bytes + ' B'
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
+ }
+
+ return (
+
+
+ {noTargetSelected ? (
+
+ ) : operatorLocked ? (
+
+ ) : crossTargetLocked ? (
+
+ ) : singleTurnLimitReached ? (
+
+ ) : (
+ <>
+
+
+ {attachments.length > 0 && (
+
+ {attachments.map((att, index) => (
+
+
+ {att.type === 'image' && '🖼️'}
+ {att.type === 'audio' && '🎵'}
+ {att.type === 'video' && '🎥'}
+ {att.type === 'file' && '📄'}
+ {' '}{att.name} ({formatFileSize(att.size)})
+
+ }
+ onClick={() => removeAttachment(index)}
+ />
+
+ ))}
+
+ )}
+
+
+ }
+ onClick={() => fileInputRef.current?.click()}
+ disabled={disabled}
+ title="Attach files"
+ />
+
+
+
+ {activeTarget && activeTarget.supports_multi_turn === false && (
+
+
+
+
+
+ )}
+ }
+ onClick={handleSend}
+ disabled={disabled || (!input && attachments.length === 0)}
+ title="Send message"
+ />
+
+
+
+ >
+ )}
+
+
+ )
+})
+
+export default ChatInputArea
diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx
index 41f2d6d489..ee1f197734 100644
--- a/frontend/src/components/Chat/ChatWindow.test.tsx
+++ b/frontend/src/components/Chat/ChatWindow.test.tsx
@@ -1,12 +1,212 @@
-import { render, screen, waitFor, act } from "@testing-library/react";
+import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FluentProvider, webLightTheme } from "@fluentui/react-components";
import ChatWindow from "./ChatWindow";
-import { Message } from "../../types";
+import { Message, TargetInfo, TargetInstance } from "../../types";
+import { attacksApi } from "../../services/api";
+import * as messageMapper from "../../utils/messageMapper";
-const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
- {children}
-);
+jest.mock("../../services/api", () => ({
+ attacksApi: {
+ createAttack: jest.fn(),
+ addMessage: jest.fn(),
+ getMessages: jest.fn(),
+ getRelatedConversations: jest.fn(),
+ getConversations: jest.fn(),
+ createConversation: jest.fn(),
+ changeMainConversation: jest.fn(),
+ },
+ labelsApi: {
+ getLabels: jest.fn().mockImplementation(() => new Promise(() => {})),
+ },
+}));
+
+jest.mock("../../utils/messageMapper", () => ({
+ buildMessagePieces: jest.fn(),
+ backendMessagesToFrontend: jest.fn(),
+}));
+
+const mockedAttacksApi = attacksApi as jest.Mocked;
+const mockedMapper = messageMapper as jest.Mocked;
+
+const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
+ children,
+}) => {children};
+
+const mockTarget: TargetInstance = {
+ target_registry_name: "openai_chat_1",
+ target_type: "OpenAIChatTarget",
+ endpoint: "https://api.openai.com",
+ model_name: "gpt-4",
+};
+
+// ---------------------------------------------------------------------------
+// Helpers to build mock backend responses
+// ---------------------------------------------------------------------------
+
+function makeTextResponse(text: string) {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-resp",
+ original_value_data_type: "text",
+ converted_value_data_type: "text",
+ original_value: text,
+ converted_value: text,
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
+
+function makeImageResponse() {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-img",
+ original_value_data_type: "text",
+ converted_value_data_type: "image_path",
+ original_value: "generated image",
+ converted_value: "iVBORw0KGgo=",
+ converted_value_mime_type: "image/png",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
+
+function makeAudioResponse() {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-aud",
+ original_value_data_type: "text",
+ converted_value_data_type: "audio_path",
+ original_value: "spoken text",
+ converted_value: "UklGRg==",
+ converted_value_mime_type: "audio/wav",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
+
+function makeVideoResponse() {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-vid",
+ original_value_data_type: "text",
+ converted_value_data_type: "video_path",
+ original_value: "generated video",
+ converted_value: "dmlkZW8=",
+ converted_value_mime_type: "video/mp4",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
+
+function makeMultiModalResponse() {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-text",
+ original_value_data_type: "text",
+ converted_value_data_type: "text",
+ original_value: "Here is the result:",
+ converted_value: "Here is the result:",
+ scores: [],
+ response_error: "none",
+ },
+ {
+ piece_id: "p-img2",
+ original_value_data_type: "text",
+ converted_value_data_type: "image_path",
+ original_value: "image content",
+ converted_value: "aW1hZ2U=",
+ converted_value_mime_type: "image/jpeg",
+ scores: [],
+ response_error: "none",
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
+
+function makeErrorResponse(errorType: string, description: string) {
+ return {
+ messages: {
+ messages: [
+ {
+ turn_number: 1,
+ role: "assistant",
+ pieces: [
+ {
+ piece_id: "p-err",
+ original_value_data_type: "text",
+ converted_value_data_type: "text",
+ original_value: "",
+ converted_value: "",
+ scores: [],
+ response_error: errorType,
+ response_error_description: description,
+ },
+ ],
+ created_at: "2026-01-01T00:00:01Z",
+ },
+ ],
+ },
+ };
+}
describe("ChatWindow Integration", () => {
const mockMessages: Message[] = [
@@ -23,20 +223,33 @@ describe("ChatWindow Integration", () => {
];
const defaultProps = {
- messages: [],
+ messages: [] as Message[],
onSendMessage: jest.fn(),
onReceiveMessage: jest.fn(),
- onNewChat: jest.fn(),
+ onNewAttack: jest.fn(),
+ activeTarget: mockTarget,
+ attackResultId: null as string | null,
+ conversationId: null as string | null,
+ activeConversationId: null as string | null,
+ onConversationCreated: jest.fn(),
+ onSelectConversation: jest.fn(),
+ onSetMessages: jest.fn(),
+ labels: { operator: 'testuser', operation: 'test_op' },
+ onLabelsChange: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
- jest.useFakeTimers();
+ // Default: panel API returns empty conversations
+ mockedAttacksApi.getConversations.mockResolvedValue({
+ conversations: [],
+ main_conversation_id: null,
+ });
});
- afterEach(() => {
- jest.useRealTimers();
- });
+ // -----------------------------------------------------------------------
+ // Basic rendering
+ // -----------------------------------------------------------------------
it("should render chat window with all components", () => {
render(
@@ -45,8 +258,8 @@ describe("ChatWindow Integration", () => {
);
- expect(screen.getByText("PyRIT Frontend")).toBeInTheDocument();
- expect(screen.getByText("New Chat")).toBeInTheDocument();
+ expect(screen.getByText("PyRIT Attack")).toBeInTheDocument();
+ expect(screen.getByText("New Attack")).toBeInTheDocument();
expect(screen.getByRole("textbox")).toBeInTheDocument();
});
@@ -61,95 +274,1974 @@ describe("ChatWindow Integration", () => {
expect(screen.getByText("Hi there!")).toBeInTheDocument();
});
- it("should call onNewChat when New Chat button is clicked", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
- const onNewChat = jest.fn();
+ it("should show target info when target is active", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/OpenAIChatTarget/)).toBeInTheDocument();
+ expect(screen.getByText(/gpt-4/)).toBeInTheDocument();
+ });
+
+ it("should show no-target message when target is null", () => {
+ render(
+
+
+
+ );
+
+ // Banner in ChatInputArea area
+ expect(screen.getByTestId("no-target-banner")).toBeInTheDocument();
+ expect(screen.getByTestId("configure-target-input-btn")).toBeInTheDocument();
+ });
+
+ it("should call onNewAttack when New Attack button is clicked", async () => {
+ const user = userEvent.setup();
+ const onNewAttack = jest.fn();
+ const existingMessages: Message[] = [
+ { role: "user", content: "hello", timestamp: "2024-01-01T00:00:00Z" },
+ ];
+
+ render(
+
+
+
+ );
+
+ await user.click(screen.getByText("New Attack"));
+ expect(onNewAttack).toHaveBeenCalled();
+ });
+
+ it("should show no-target banner when no target is selected", () => {
render(
-
+
);
- await user.click(screen.getByText("New Chat"));
+ // ChatInputArea shows a red warning banner instead of the text input
+ expect(screen.getByTestId("no-target-banner")).toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+
+ // -----------------------------------------------------------------------
+ // Target info display for various target types
+ // -----------------------------------------------------------------------
- expect(onNewChat).toHaveBeenCalled();
+ it("should display target without model name", () => {
+ const targetNoModel: TargetInstance = {
+ ...mockTarget,
+ model_name: null,
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByText(/OpenAIChatTarget/)).toBeInTheDocument();
+ expect(screen.queryByText(/gpt/)).not.toBeInTheDocument();
});
- it("should call onSendMessage when message is sent", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ // -----------------------------------------------------------------------
+ // First message → create attack + send
+ // -----------------------------------------------------------------------
+
+ it("should create attack and send text message on first message", async () => {
+ const user = userEvent.setup();
const onSendMessage = jest.fn();
+ const onReceiveMessage = jest.fn();
+ const onConversationCreated = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Hello" },
+ ]);
+ mockedAttacksApi.createAttack.mockResolvedValue({
+ attack_result_id: "ar-conv-1",
+ conversation_id: "conv-1",
+ created_at: "2026-01-01T00:00:00Z",
+ });
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Hello back!") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Hello back!",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ ]);
render(
-
+
);
const input = screen.getByRole("textbox");
- await user.type(input, "Test message");
+ await user.type(input, "Hello");
await user.click(screen.getByRole("button", { name: /send/i }));
- expect(onSendMessage).toHaveBeenCalledWith(
- expect.objectContaining({
+ await waitFor(() => {
+ expect(onSendMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ role: "user", content: "Hello" })
+ );
+ expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith({
+ target_registry_name: "openai_chat_1",
+ labels: { operator: 'testuser', operation: 'test_op' },
+ });
+ expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-1", "conv-1");
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith("ar-conv-1", {
role: "user",
- content: "Test message",
- })
+ pieces: [{ data_type: "text", original_value: "Hello" }],
+ send: true,
+ target_registry_name: "openai_chat_1",
+ target_conversation_id: "conv-1",
+ labels: { operator: "testuser", operation: "test_op" },
+ });
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Subsequent messages → reuse conversation ID
+ // -----------------------------------------------------------------------
+
+ it("should reuse conversationId on subsequent messages", async () => {
+ const user = userEvent.setup();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Second" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Response") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Response",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
+
+ render(
+
+
+
);
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Second");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createAttack).not.toHaveBeenCalled();
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-existing-conv",
+ expect.any(Object)
+ );
+ });
});
- it("should call onReceiveMessage after sending", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ // -----------------------------------------------------------------------
+ // Error handling
+ // -----------------------------------------------------------------------
+
+ it("should show error message when API call fails", async () => {
+ const user = userEvent.setup();
const onReceiveMessage = jest.fn();
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+ mockedAttacksApi.createAttack.mockRejectedValue(
+ new Error("Network error")
+ );
+
render(
-
+
);
const input = screen.getByRole("textbox");
- await user.type(input, "Hello");
+ await user.type(input, "test");
await user.click(screen.getByRole("button", { name: /send/i }));
- // Advance timers to trigger the echo response (wrapped in act)
- await act(async () => {
- jest.advanceTimersByTime(600);
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ role: "assistant",
+ error: expect.objectContaining({
+ type: "unknown",
+ description: "Network error",
+ }),
+ })
+ );
+ });
+ });
+
+ it("should show error message when addMessage fails", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+ mockedAttacksApi.createAttack.mockResolvedValue({
+ attack_result_id: "ar-conv-err",
+ conversation_id: "conv-err",
+ created_at: "2026-01-01T00:00:00Z",
});
+ mockedAttacksApi.addMessage.mockRejectedValue(
+ new Error("Request failed with status code 404")
+ );
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
await waitFor(() => {
expect(onReceiveMessage).toHaveBeenCalledWith(
expect.objectContaining({
role: "assistant",
- content: "Echo: Hello",
+ error: expect.objectContaining({
+ description: "Request failed with status code 404",
+ }),
})
);
});
});
- it("should disable input while sending", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ it("should extract detail from axios-style error response", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+
+ // Simulate an axios error with response.data.detail (what FastAPI returns)
+ const axiosError = new Error("Request failed with status code 500") as Error & { isAxiosError: boolean; response: { status: number; data: { detail: string } } };
+ axiosError.isAxiosError = true;
+ axiosError.response = {
+ status: 500,
+ data: { detail: "Failed to add message: Image URLs are only allowed for messages with role 'user'" },
+ };
+ mockedAttacksApi.addMessage.mockRejectedValue(axiosError);
render(
-
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ description: "Failed to add message: Image URLs are only allowed for messages with role 'user'",
+ }),
+ })
+ );
+ });
+ });
+
+ it("should extract plain string from axios-style error response", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+
+ // Simulate a response where data is a plain string (not JSON)
+ const axiosError = new Error("Request failed with status code 500") as Error & { isAxiosError: boolean; response: { status: number; data: string } };
+ axiosError.isAxiosError = true;
+ axiosError.response = {
+ status: 500,
+ data: "Internal Server Error",
+ };
+ mockedAttacksApi.addMessage.mockRejectedValue(axiosError);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ description: "Internal Server Error",
+ }),
+ })
+ );
+ });
+ });
+
+ it("should show generic error for non-Error thrown values", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+ mockedAttacksApi.addMessage.mockRejectedValue("string error");
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ description: "string error",
+ }),
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Loading indicator flow
+ // -----------------------------------------------------------------------
+
+ it("should show loading then replace with response", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Hello" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Hi!") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Hi!",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Hello");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ // Loading message delivered via onReceiveMessage
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({ content: "...", isLoading: true })
+ );
+ // Actual response delivered via onSetMessages (full server data)
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({ content: "Hi!" }),
+ ])
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: image response
+ // -----------------------------------------------------------------------
+
+ it("should handle image response from backend", async () => {
+ const user = userEvent.setup();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Generate an image" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeImageResponse() as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "",
+ timestamp: "2026-01-01T00:00:01Z",
+ attachments: [
+ {
+ type: "image" as const,
+ name: "image_path_p-img",
+ url: "data:image/png;base64,iVBORw0KGgo=",
+ mimeType: "image/png",
+ size: 12,
+ },
+ ],
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Generate an image");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ // The response should include the image attachment
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ attachments: expect.arrayContaining([
+ expect.objectContaining({ type: "image" }),
+ ]),
+ }),
+ ])
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: audio response
+ // -----------------------------------------------------------------------
+
+ it("should handle audio response from backend", async () => {
+ const user = userEvent.setup();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Read this aloud" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeAudioResponse() as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "",
+ timestamp: "2026-01-01T00:00:01Z",
+ attachments: [
+ {
+ type: "audio" as const,
+ name: "audio_path_p-aud",
+ url: "data:audio/wav;base64,UklGRg==",
+ mimeType: "audio/wav",
+ size: 8,
+ },
+ ],
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Read this aloud");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ attachments: expect.arrayContaining([
+ expect.objectContaining({ type: "audio" }),
+ ]),
+ }),
+ ])
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: video response
+ // -----------------------------------------------------------------------
+
+ it("should handle video response from backend", async () => {
+ const user = userEvent.setup();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Create a video" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeVideoResponse() as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "",
+ timestamp: "2026-01-01T00:00:01Z",
+ attachments: [
+ {
+ type: "video" as const,
+ name: "video_path_p-vid",
+ url: "data:video/mp4;base64,dmlkZW8=",
+ mimeType: "video/mp4",
+ size: 8,
+ },
+ ],
+ },
+ ]);
+
+ render(
+
+
);
const input = screen.getByRole("textbox");
- await user.type(input, "Test");
+ await user.type(input, "Create a video");
await user.click(screen.getByRole("button", { name: /send/i }));
- // Input should be disabled while waiting for response
- expect(input).toBeDisabled();
+ await waitFor(() => {
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ attachments: expect.arrayContaining([
+ expect.objectContaining({ type: "video" }),
+ ]),
+ }),
+ ])
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: mixed text + image response
+ // -----------------------------------------------------------------------
+
+ it("should handle mixed text + image response", async () => {
+ const user = userEvent.setup();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Describe and show" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeMultiModalResponse() as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Here is the result:",
+ timestamp: "2026-01-01T00:00:01Z",
+ attachments: [
+ {
+ type: "image" as const,
+ name: "image_path_p-img2",
+ url: "data:image/jpeg;base64,aW1hZ2U=",
+ mimeType: "image/jpeg",
+ size: 8,
+ },
+ ],
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Describe and show");
+ await user.click(screen.getByRole("button", { name: /send/i }));
- // Advance timers to complete the send (wrapped in act)
- await act(async () => {
- jest.advanceTimersByTime(600);
+ await waitFor(() => {
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ content: "Here is the result:",
+ attachments: expect.arrayContaining([
+ expect.objectContaining({ type: "image" }),
+ ]),
+ }),
+ ])
+ );
});
+ });
+
+ // -----------------------------------------------------------------------
+ // Sending image attachment
+ // -----------------------------------------------------------------------
+
+ it("should send image attachment alongside text", async () => {
+ const user = userEvent.setup();
+ const onSendMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "What is this?" },
+ {
+ data_type: "image_path",
+ original_value: "iVBORw0KGgo=",
+ mime_type: "image/png",
+ },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("It's a cat.") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "It's a cat.",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "What is this?");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-conv-attach",
+ expect.objectContaining({
+ pieces: [
+ { data_type: "text", original_value: "What is this?" },
+ {
+ data_type: "image_path",
+ original_value: "iVBORw0KGgo=",
+ mime_type: "image/png",
+ },
+ ],
+ send: true,
+ target_conversation_id: "conv-attach",
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Sending audio attachment
+ // -----------------------------------------------------------------------
+
+ it("should send audio attachment", async () => {
+ const user = userEvent.setup();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ {
+ data_type: "audio_path",
+ original_value: "UklGRg==",
+ mime_type: "audio/wav",
+ },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(
+ makeTextResponse("Transcribed: hello") as never
+ );
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Transcribed: hello",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Listen");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-conv-aud-send",
+ expect.objectContaining({
+ pieces: [
+ {
+ data_type: "audio_path",
+ original_value: "UklGRg==",
+ mime_type: "audio/wav",
+ },
+ ],
+ target_conversation_id: "conv-aud-send",
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Backend error in response piece (blocked, processing, etc.)
+ // -----------------------------------------------------------------------
+
+ it("should handle blocked response from target", async () => {
+ const user = userEvent.setup();
+ const onSetMessages = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "bad prompt" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(
+ makeErrorResponse("blocked", "Content was filtered by safety system") as never
+ );
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "",
+ timestamp: "2026-01-01T00:00:01Z",
+ error: {
+ type: "blocked",
+ description: "Content was filtered by safety system",
+ },
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "bad prompt");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onSetMessages).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ expect.objectContaining({
+ role: "assistant",
+ error: expect.objectContaining({ type: "blocked" }),
+ }),
+ ])
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-turn conversation
+ // -----------------------------------------------------------------------
+
+ it("should support multi-turn: create on first, reuse on second", async () => {
+ const user = userEvent.setup();
+ const onConversationCreated = jest.fn();
+ const onSendMessage = jest.fn();
+ const onReceiveMessage = jest.fn();
+
+ // First message
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Turn 1" },
+ ]);
+ mockedAttacksApi.createAttack.mockResolvedValue({
+ attack_result_id: "ar-conv-multi-turn",
+ conversation_id: "conv-multi-turn",
+ created_at: "2026-01-01T00:00:00Z",
+ });
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Reply 1") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Reply 1",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
+
+ const { rerender } = render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Turn 1");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createAttack).toHaveBeenCalledTimes(1);
+ expect(onConversationCreated).toHaveBeenCalledWith("ar-conv-multi-turn", "conv-multi-turn");
+ });
+
+ // Now rerender with the conversation ID set (simulating parent state update)
+ jest.clearAllMocks();
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Turn 2" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Reply 2") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "assistant",
+ content: "Reply 2",
+ timestamp: "2026-01-01T00:00:02Z",
+ },
+ ]);
+
+ rerender(
+
+
+
+ );
+
+ await user.type(screen.getByRole("textbox"), "Turn 2");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createAttack).not.toHaveBeenCalled();
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-conv-multi-turn",
+ expect.objectContaining({
+ pieces: [{ data_type: "text", original_value: "Turn 2" }],
+ target_conversation_id: "conv-multi-turn",
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-turn with mixed modalities
+ // -----------------------------------------------------------------------
+
+ it("should support sending text first then image in second turn", async () => {
+ const user = userEvent.setup();
+
+ // Turn 1: text
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Hello" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Hi!") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ { role: "assistant", content: "Hi!", timestamp: "2026-01-01T00:00:01Z" },
+ ]);
+
+ const { rerender } = render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "Hello");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledTimes(1);
+ });
+
+ // Turn 2: text + image
+ jest.clearAllMocks();
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "What is this?" },
+ { data_type: "image_path", original_value: "base64data", mime_type: "image/png" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("A cat") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ { role: "assistant", content: "A cat", timestamp: "2026-01-01T00:00:02Z" },
+ ]);
+
+ rerender(
+
+
+
+ );
+
+ await user.type(screen.getByRole("textbox"), "What is this?");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-conv-mixed-turns",
+ expect.objectContaining({
+ pieces: [
+ { data_type: "text", original_value: "What is this?" },
+ { data_type: "image_path", original_value: "base64data", mime_type: "image/png" },
+ ],
+ target_conversation_id: "conv-mixed-turns",
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // No message sent when target is null (guard)
+ // -----------------------------------------------------------------------
+
+ it("should show no-target banner when active target is null", () => {
+ render(
+
+
+
+ );
+
+ // ChatInputArea shows banner instead of textbox
+ expect(screen.getByTestId("no-target-banner")).toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+
+ // -----------------------------------------------------------------------
+ // Single-turn target UX
+ // -----------------------------------------------------------------------
+
+ it("should show single-turn banner for single-turn target with existing user messages", () => {
+ const singleTurnTarget: TargetInstance = {
+ target_registry_name: "openai_image_1",
+ target_type: "OpenAIImageTarget",
+ supports_multi_turn: false,
+ };
+
+ const messagesWithUser: Message[] = [
+ { role: "user", content: "Generate an image", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "Here is the image", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("single-turn-banner")).toBeInTheDocument();
+ expect(screen.getByText(/only supports single-turn/)).toBeInTheDocument();
+ expect(screen.queryByRole("textbox")).not.toBeInTheDocument();
+ });
+
+ it("should not show single-turn banner for single-turn target with no messages", () => {
+ const singleTurnTarget: TargetInstance = {
+ target_registry_name: "openai_image_1",
+ target_type: "OpenAIImageTarget",
+ supports_multi_turn: false,
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
+
+ it("should not show single-turn banner for multiturn target with messages", () => {
+ const messagesWithUser: Message[] = [
+ { role: "user", content: "Hello", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "Hi there", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("single-turn-banner")).not.toBeInTheDocument();
+ expect(screen.getByRole("textbox")).toBeInTheDocument();
+ });
+
+ it("should show New Conversation button in single-turn banner when conversation exists", () => {
+ const singleTurnTarget: TargetInstance = {
+ target_registry_name: "openai_tts_1",
+ target_type: "OpenAITTSTarget",
+ supports_multi_turn: false,
+ };
+
+ const messagesWithUser: Message[] = [
+ { role: "user", content: "Say hello", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "Audio output", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("new-conversation-btn")).toBeInTheDocument();
+ });
+
+ it("should show cross-target banner when attackTarget differs from activeTarget", () => {
+ const differentTarget: TargetInfo = {
+ target_type: "AzureOpenAIChatTarget",
+ endpoint: "https://azure.openai.com",
+ model_name: "gpt-4o",
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.getByTestId("cross-target-banner")).toBeInTheDocument();
+ });
+
+ it("should not show cross-target banner when attackTarget matches activeTarget", () => {
+ const sameTarget: TargetInfo = {
+ target_type: mockTarget.target_type,
+ endpoint: mockTarget.endpoint,
+ model_name: mockTarget.model_name,
+ };
+
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("cross-target-banner")).not.toBeInTheDocument();
+ });
+
+ it("should auto-open conversation panel when relatedConversationCount > 0", async () => {
+ mockedAttacksApi.getRelatedConversations.mockResolvedValue({
+ conversations: [
+ { conversation_id: "conv-main", is_main: true },
+ { conversation_id: "conv-related", is_main: false },
+ ],
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({
+ messages: [],
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ });
+ });
+
+ it("should not auto-open conversation panel when relatedConversationCount is 0", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
+ });
+
+ it("should open conversation panel when branching a conversation", async () => {
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello", data_type: "text" },
+ { role: "assistant", content: "hi there", data_type: "text" },
+ ];
+
+ // Mock getMessages so loadConversation resolves and clears loading state
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+ mockedAttacksApi.createConversation.mockResolvedValue({
+ conversation_id: "new-conv-branched",
+ });
+
+ render(
+
+
+
+ );
+
+ // Wait for loading to complete (loadConversation resolves)
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ // Panel should NOT be open initially
+ expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
+
+ // Click the branch-conversation button on the assistant message (index 1)
+ const branchBtn = screen.getByTestId("branch-conv-btn-1");
+ await userEvent.click(branchBtn);
+
+ // Panel should now be open
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ });
+ });
+
+ it("should open conversation panel when copying to new conversation", async () => {
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello", data_type: "text" },
+ { role: "assistant", content: "hi there", data_type: "text" },
+ ];
+
+ // Mock getMessages so loadConversation resolves and clears loading state
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+ mockedAttacksApi.createConversation.mockResolvedValue({
+ conversation_id: "new-conv-copied",
+ });
+
+ render(
+
+
+
+ );
+
+ // Wait for loading to complete
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ // Panel should NOT be open initially
+ expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
+
+ // Click the copy-to-new-conversation button on the assistant message (index 1)
+ const copyBtn = screen.getByTestId("copy-to-new-conv-btn-1");
+ await userEvent.click(copyBtn);
+
+ // Panel should now be open
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleNewConversation
+ // -----------------------------------------------------------------------
+
+ it("should create a new conversation and select it via handleNewConversation", async () => {
+ const onSelectConversation = jest.fn();
+ mockedAttacksApi.createConversation.mockResolvedValue({
+ conversation_id: "new-conv-from-new",
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([]);
+
+ const singleTurnTarget: TargetInstance = {
+ target_registry_name: "openai_image_1",
+ target_type: "OpenAIImageTarget",
+ supports_multi_turn: false,
+ };
+
+ const messagesWithUser: Message[] = [
+ { role: "user", content: "Generate an image", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "Here is the image", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ render(
+
+
+
+ );
+
+ // 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 waitFor(() => {
+ expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith("ar-new-conv", {});
+ expect(onSelectConversation).toHaveBeenCalledWith("new-conv-from-new");
+ });
+ });
+
+ it("should not create conversation when attackResultId is null", async () => {
+ const onSelectConversation = jest.fn();
+
+ render(
+
+
+
+ );
+
+ // No new-conversation button should be available without an attackResultId
+ expect(screen.queryByTestId("new-conversation-btn")).not.toBeInTheDocument();
+ expect(mockedAttacksApi.createConversation).not.toHaveBeenCalled();
+ });
+
+ // -----------------------------------------------------------------------
+ // handleCopyToInput
+ // -----------------------------------------------------------------------
+
+ it("should copy message content to input box via copy-to-input button", async () => {
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "This is the response text" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ // Click copy-to-input on assistant message (index 1)
+ const copyBtn = screen.getByTestId("copy-to-input-btn-1");
+ await userEvent.click(copyBtn);
+
+ // The text should appear in the input area
+ await waitFor(() => {
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ expect(textarea.value).toBe("This is the response text");
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleCopyToNewConversation
+ // -----------------------------------------------------------------------
+
+ it("should create a new conversation and copy message when copy-to-new-conv is clicked", async () => {
+ const onSelectConversation = jest.fn();
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "reply text to copy" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+ mockedAttacksApi.createConversation.mockResolvedValue({
+ conversation_id: "new-conv-copy",
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ const copyBtn = screen.getByTestId("copy-to-new-conv-btn-1");
+ await userEvent.click(copyBtn);
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith("ar-copy-new", {});
+ expect(onSelectConversation).toHaveBeenCalledWith("new-conv-copy");
+ });
+ });
+
+ it("should fall back when createConversation fails in copy-to-new-conversation", async () => {
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "fallback text" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+ mockedAttacksApi.createConversation.mockRejectedValue(new Error("Failed"));
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ const copyBtn = screen.getByTestId("copy-to-new-conv-btn-1");
+ await userEvent.click(copyBtn);
+
+ // Should fall back to setting text in current input
+ await waitFor(() => {
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ expect(textarea.value).toBe("fallback text");
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleBranchConversation
+ // -----------------------------------------------------------------------
+
+ it("should branch conversation and load cloned messages", async () => {
+ const onSelectConversation = jest.fn();
+ const onSetMessages = jest.fn();
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "response" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+ mockedAttacksApi.createConversation.mockResolvedValue({
+ conversation_id: "branched-conv",
+ });
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ const branchBtn = screen.getByTestId("branch-conv-btn-1");
+ await userEvent.click(branchBtn);
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createConversation).toHaveBeenCalledWith("ar-branch-test", {
+ source_conversation_id: "conv-branch-test",
+ cutoff_index: 1,
+ });
+ expect(onSelectConversation).toHaveBeenCalledWith("branched-conv");
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleBranchAttack
+ // -----------------------------------------------------------------------
+
+ it("should branch into a new attack and load cloned messages", async () => {
+ const onConversationCreated = jest.fn();
+ const onSetMessages = jest.fn();
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ { role: "assistant", content: "response" },
+ ];
+ const clonedMessages: Message[] = [
+ { role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ // Set up mocks for the branch attack flow
+ mockedAttacksApi.createAttack.mockResolvedValue({
+ attack_result_id: "ar-new-branch",
+ conversation_id: "conv-new-branch",
+ created_at: "2026-01-01T00:00:00Z",
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(clonedMessages);
+
+ const branchBtn = screen.getByTestId("branch-attack-btn-1");
+ await userEvent.click(branchBtn);
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target_registry_name: "openai_chat_1",
+ source_conversation_id: "conv-branch-attack",
+ cutoff_index: 1,
+ })
+ );
+ expect(onConversationCreated).toHaveBeenCalledWith("ar-new-branch", "conv-new-branch");
+ expect(onSetMessages).toHaveBeenCalledWith(clonedMessages);
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleChangeMainConversation
+ // -----------------------------------------------------------------------
+
+ it("should call changeMainConversation API via conversation panel", async () => {
+ mockedAttacksApi.getConversations.mockResolvedValue({
+ conversations: [
+ { conversation_id: "conv-main", is_main: true, message_count: 2, created_at: "2026-01-01T00:00:00Z" },
+ { conversation_id: "conv-alt", is_main: false, message_count: 1, created_at: "2026-01-01T00:01:00Z" },
+ ],
+ main_conversation_id: "conv-main",
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([]);
+ mockedAttacksApi.changeMainConversation.mockResolvedValue({});
+
+ render(
+
+
+
+ );
+
+ // Panel should auto-open due to relatedConversationCount > 0
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ });
+
+ // Wait for conversations to load in panel
+ await waitFor(() => {
+ expect(screen.getByTestId("star-btn-conv-alt")).toBeInTheDocument();
+ });
+
+ await userEvent.click(screen.getByTestId("star-btn-conv-alt"));
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.changeMainConversation).toHaveBeenCalledWith(
+ "ar-main-change",
+ "conv-alt"
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // handleUseAsTemplate
+ // -----------------------------------------------------------------------
+
+ it("should create new attack from template when use-as-template button is clicked", async () => {
+ const onConversationCreated = jest.fn();
+ const onSetMessages = jest.fn();
+ const existingMessages: Message[] = [
+ { role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "response", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ const differentTarget: TargetInfo = {
+ target_type: "AzureOpenAIChatTarget",
+ endpoint: "https://azure.openai.com",
+ model_name: "gpt-4o",
+ };
+
+ const templateMessages: Message[] = [
+ { role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(existingMessages);
+ mockedAttacksApi.createAttack.mockResolvedValue({
+ attack_result_id: "ar-template",
+ conversation_id: "conv-template",
+ created_at: "2026-01-01T00:00:00Z",
+ });
+
+ render(
+
+
+
+ );
+
+ // Cross-target banner should appear
+ await waitFor(() => {
+ expect(screen.getByTestId("cross-target-banner")).toBeInTheDocument();
+ });
+
+ // Reconfigure mocks for the template creation
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(templateMessages);
+
+ const useTemplateBtn = screen.getByTestId("use-as-template-btn");
+ await userEvent.click(useTemplateBtn);
+
+ await waitFor(() => {
+ expect(mockedAttacksApi.createAttack).toHaveBeenCalledWith(
+ expect.objectContaining({
+ target_registry_name: "openai_chat_1",
+ source_conversation_id: "conv-cross-template",
+ cutoff_index: 1,
+ })
+ );
+ expect(onConversationCreated).toHaveBeenCalledWith("ar-template", "conv-template");
+ });
+ });
+
+ it("should show operator locked banner and use-as-template when operator differs", async () => {
+ const existingMessages: Message[] = [
+ { role: "user", content: "hello", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "response", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(existingMessages);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("operator-locked-banner")).toBeInTheDocument();
+ });
+
+ expect(screen.getByTestId("use-as-template-btn")).toBeInTheDocument();
+ });
+
+ // -----------------------------------------------------------------------
+ // Cross-target locking rendering details
+ // -----------------------------------------------------------------------
+
+ it("should render conversation panel as locked when cross-target locked", async () => {
+ const differentTarget: TargetInfo = {
+ target_type: "AzureOpenAIChatTarget",
+ endpoint: "https://azure.openai.com",
+ model_name: "gpt-4o",
+ };
+
+ mockedAttacksApi.getRelatedConversations.mockResolvedValue({
+ conversations: [
+ { conversation_id: "conv-cross-panel", is_main: true, message_count: 2, created_at: "2026-01-01T00:00:00Z" },
+ ],
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([]);
+
+ render(
+
+
+
+ );
+
+ // Panel should auto-open and the cross-target banner should appear
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("cross-target-banner")).toBeInTheDocument();
+ });
+ });
+
+ it("should not show cross-target banner when attackTarget is null", () => {
+ render(
+
+
+
+ );
+
+ expect(screen.queryByTestId("cross-target-banner")).not.toBeInTheDocument();
+ });
+
+ // -----------------------------------------------------------------------
+ // Network error in handleSend
+ // -----------------------------------------------------------------------
+
+ it("should show network error when addMessage fails with network error", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+
+ const networkError = new Error("Network Error") as Error & {
+ isAxiosError: boolean;
+ response: undefined;
+ code: undefined;
+ };
+ networkError.isAxiosError = true;
+ (networkError as Record).response = undefined;
+ mockedAttacksApi.addMessage.mockRejectedValue(networkError);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ type: "network",
+ description: expect.stringContaining("Network error"),
+ }),
+ })
+ );
+ });
+ });
+
+ it("should show timeout error when addMessage fails with timeout", async () => {
+ const user = userEvent.setup();
+ const onReceiveMessage = jest.fn();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "test" },
+ ]);
+
+ const timeoutError = new Error("timeout") as Error & {
+ isAxiosError: boolean;
+ code: string;
+ };
+ timeoutError.isAxiosError = true;
+ (timeoutError as Record).code = "ECONNABORTED";
+ mockedAttacksApi.addMessage.mockRejectedValue(timeoutError);
+
+ render(
+
+
+
+ );
+
+ const input = screen.getByRole("textbox");
+ await user.type(input, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(onReceiveMessage).toHaveBeenCalledWith(
+ expect.objectContaining({
+ error: expect.objectContaining({
+ type: "timeout",
+ description: expect.stringContaining("timed out"),
+ }),
+ })
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Toggle panel button
+ // -----------------------------------------------------------------------
+
+ it("should toggle conversation panel when toggle-panel button is clicked", async () => {
+ mockedAttacksApi.getRelatedConversations.mockResolvedValue({
+ conversations: [
+ { conversation_id: "conv-toggle-main", is_main: true, message_count: 1, created_at: "2026-01-01T00:00:00Z" },
+ ],
+ });
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([]);
+
+ render(
+
+
+
+ );
+
+ // Panel should not be open initially (relatedConversationCount=0)
+ expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
+
+ // Click toggle button to open panel
+ const toggleBtn = screen.getByTestId("toggle-panel-btn");
+ await userEvent.click(toggleBtn);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("conversation-panel")).toBeInTheDocument();
+ });
+
+ // Click toggle button again to close panel
+ await userEvent.click(toggleBtn);
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Copy to input with attachments
+ // -----------------------------------------------------------------------
+
+ it("should copy message with attachments to input box", async () => {
+ const mockMessages: Message[] = [
+ { role: "user", content: "hello" },
+ {
+ role: "assistant",
+ content: "Here is an image",
+ attachments: [
+ {
+ type: "image" as const,
+ name: "test.png",
+ url: "data:image/png;base64,iVBORw0KGgo=",
+ mimeType: "image/png",
+ size: 12,
+ },
+ ],
+ },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("loading-state")).not.toBeInTheDocument();
+ });
+
+ const copyBtn = screen.getByTestId("copy-to-input-btn-1");
+ await userEvent.click(copyBtn);
+ // The text should appear in the input area
await waitFor(() => {
- expect(input).not.toBeDisabled();
+ const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ expect(textarea.value).toBe("Here is an image");
});
});
});
diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx
index 8170a866b6..f587930514 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -1,21 +1,36 @@
-import { useState } from 'react'
+import { useState, useRef, useEffect, useCallback } from 'react'
import {
makeStyles,
tokens,
Button,
Text,
+ Badge,
+ Tooltip,
} from '@fluentui/react-components'
-import { AddRegular } from '@fluentui/react-icons'
+import { AddRegular, PanelRightRegular } from '@fluentui/react-icons'
import MessageList from './MessageList'
-import InputBox from './InputBox'
-import { Message, MessageAttachment } from '../../types'
+import ChatInputArea from './ChatInputArea'
+import ConversationPanel from './ConversationPanel'
+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 type { Message, MessageAttachment, TargetInstance, TargetInfo } from '../../types'
+import type { ViewName } from '../Sidebar/Navigation'
const useStyles = makeStyles({
root: {
display: 'flex',
- flexDirection: 'column',
height: '100%',
width: '100%',
+ overflow: 'hidden',
+ },
+ chatArea: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minWidth: 0,
backgroundColor: tokens.colorNeutralBackground2,
overflow: 'hidden',
},
@@ -38,25 +53,160 @@ const useStyles = makeStyles({
color: tokens.colorNeutralForeground2,
fontSize: tokens.fontSizeBase300,
},
+ targetInfo: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalXS,
+ },
+ noTarget: {
+ color: tokens.colorNeutralForeground3,
+ fontStyle: 'italic',
+ },
+ ribbonActions: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalS,
+ },
})
interface ChatWindowProps {
messages: Message[]
onSendMessage: (message: Message) => void
onReceiveMessage: (message: Message) => void
- onNewChat: () => void
+ onNewAttack: () => void
+ activeTarget: TargetInstance | null
+ attackResultId: string | null
+ conversationId: string | null
+ activeConversationId: string | null
+ onConversationCreated: (attackResultId: string, conversationId: string) => void
+ onSelectConversation: (conversationId: string) => void
+ onSetMessages: (messages: Message[]) => void
+ labels?: Record
+ onLabelsChange?: (labels: Record) => void
+ onNavigate?: (view: ViewName) => void
+ /** Labels from the loaded attack (for operator locking). Null for new attacks. */
+ attackLabels?: Record | null
+ /** Target info that the current attack was started with (for cross-target guard). */
+ attackTarget?: TargetInfo | null
+ /** True while a historical attack is being loaded from the history view. */
+ isLoadingAttack?: boolean
+ /** Number of related (non-main) conversations in the loaded attack. */
+ relatedConversationCount?: number
}
export default function ChatWindow({
messages,
onSendMessage,
onReceiveMessage,
- onNewChat,
+ onNewAttack,
+ activeTarget,
+ attackResultId,
+ conversationId,
+ activeConversationId,
+ onConversationCreated,
+ onSelectConversation,
+ onSetMessages,
+ labels,
+ onLabelsChange,
+ onNavigate,
+ attackLabels,
+ attackTarget,
+ isLoadingAttack,
+ relatedConversationCount,
}: ChatWindowProps) {
const styles = useStyles()
- const [isSending, setIsSending] = useState(false)
+ // Track sending state per conversation so parallel conversations can send independently
+ const [sendingConversations, setSendingConversations] = useState>(new Set())
+ /** True while an async message fetch is in-flight */
+ const [isLoadingMessages, setIsLoadingMessages] = useState(false)
+ /** Which conversation's messages are currently loaded (set after fetch completes) */
+ const [loadedConversationId, setLoadedConversationId] = useState(null)
+ const isSending = activeConversationId ? sendingConversations.has(activeConversationId) : Boolean(sendingConversations.size)
+ const [isPanelOpen, setIsPanelOpen] = useState(false)
+ const [panelRefreshKey, setPanelRefreshKey] = useState(0)
+ const inputBoxRef = useRef(null)
+
+ // Auto-open conversation sidebar when loading a historical attack with multiple conversations
+ useEffect(() => {
+ if (relatedConversationCount && relatedConversationCount > 0) {
+ setIsPanelOpen(true)
+ }
+ }, [attackResultId, relatedConversationCount])
+ // Always-current ref of the conversation being viewed so async callbacks can
+ // check whether the user navigated away while a request was in-flight.
+ const viewedConvRef = useRef(activeConversationId ?? conversationId)
+ useEffect(() => { viewedConvRef.current = activeConversationId ?? conversationId }, [activeConversationId, conversationId])
+ // Synchronous ref tracking which conversations have an in-flight send.
+ const sendingConvIdsRef = useRef>(new Set())
+ // Pending user messages per conversation that may not be stored server-side yet.
+ // Used to restore the user's input when switching back to an in-flight conversation.
+ const pendingUserMessagesRef = useRef