+
+
+
+
{children}
);
};
+ MockMainLayout.displayName = "MockMainLayout";
+ return {
+ __esModule: true,
+ default: MockMainLayout,
+ };
});
jest.mock("./components/Chat/ChatWindow", () => {
- return function MockChatWindow({
- messages,
- onSendMessage,
- onReceiveMessage,
- onNewChat,
+ const MockChatWindow = ({
+ onNewAttack,
+ activeTarget,
+ conversationId,
+ onConversationCreated,
+ onSelectConversation,
}: {
- messages: Array<{ id: string; content: string }>;
- onSendMessage: (msg: { id: string; content: string }) => void;
- onReceiveMessage: (msg: { id: string; content: string }) => void;
- onNewChat: () => 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"}
+
+
-
+ );
+ };
+ MockTargetConfig.displayName = "MockTargetConfig";
+ return {
+ __esModule: true,
+ default: MockTargetConfig,
+ };
+});
+
+jest.mock("./components/History/AttackHistory", () => {
+ const MockAttackHistory = ({
+ onOpenAttack,
+ }: {
+ onOpenAttack: (attackResultId: string) => void;
+ }) => {
+ return (
+
+
);
};
+ MockAttackHistory.displayName = "MockAttackHistory";
+ return {
+ __esModule: true,
+ default: MockAttackHistory,
+ };
});
describe("App", () => {
@@ -81,20 +194,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",
@@ -102,35 +212,149 @@ describe("App", () => {
);
});
- it("starts with empty messages", () => {
+ it("starts in chat view", () => {
render(
);
- expect(screen.getByTestId("message-count")).toHaveTextContent("0");
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "chat"
+ );
+ expect(screen.getByTestId("chat-window")).toBeInTheDocument();
});
- it("adds messages when handleSendMessage is called", () => {
+ it("switches to config view", () => {
render(
);
- fireEvent.click(screen.getByTestId("send-message"));
- expect(screen.getByTestId("message-count")).toHaveTextContent("1");
+ fireEvent.click(screen.getByTestId("nav-config"));
+
+ expect(screen.getByTestId("main-layout")).toHaveAttribute(
+ "data-current-view",
+ "config"
+ );
+ expect(screen.getByTestId("target-config")).toBeInTheDocument();
});
- it("adds messages when handleReceiveMessage is called", () => {
+ it("switches back to chat from config", () => {
render(
);
- fireEvent.click(screen.getByTestId("receive-message"));
- expect(screen.getByTestId("message-count")).toHaveTextContent("1");
+ 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("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("clears messages when handleNewChat is called", () => {
+ it("sets active conversation when onSelectConversation 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");
+ // First create a conversation to have an attack
+ fireEvent.click(screen.getByTestId("set-conversation"));
+ expect(screen.getByTestId("conversation-id")).toHaveTextContent("conv-123");
- // Clear messages
- fireEvent.click(screen.getByTestId("new-chat"));
- expect(screen.getByTestId("message-count")).toHaveTextContent("0");
+ // 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..25dd980321 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,43 +1,191 @@
-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 { 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 })
- const handleSendMessage = (message: Message) => {
- setMessages(prev => [...prev, message])
- }
+ // 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 */ })
+ }, [])
- const handleReceiveMessage = (message: Message) => {
- setMessages(prev => [...prev, message])
- }
+ 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 clearAttackState = useCallback(() => {
+ setAttackResultId(null)
+ setConversationId(null)
+ setActiveConversationId(null)
+ setAttackLabels(null)
+ setAttackTarget(null)
+ setRelatedConversationCount(0)
+ }, [])
- const handleNewChat = () => {
- setMessages([])
+ 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) => {
+ 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/ChatInputArea.styles.ts b/frontend/src/components/Chat/ChatInputArea.styles.ts
new file mode 100644
index 0000000000..75b67ff532
--- /dev/null
+++ b/frontend/src/components/Chat/ChatInputArea.styles.ts
@@ -0,0 +1,134 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useChatInputAreaStyles = 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,
+ },
+ statusBanner: {
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ gap: tokens.spacingHorizontalM,
+ padding: `${tokens.spacingVerticalM} ${tokens.spacingHorizontalL}`,
+ backgroundColor: tokens.colorNeutralBackground3,
+ borderRadius: '28px',
+ border: `1px solid ${tokens.colorNeutralStroke1}`,
+ },
+ statusBannerText: {
+ 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,
+ },
+})
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..12ef7b3d23
--- /dev/null
+++ b/frontend/src/components/Chat/ChatInputArea.tsx
@@ -0,0 +1,304 @@
+import { useState, useEffect, useLayoutEffect, useRef, forwardRef, useImperativeHandle, KeyboardEvent } from 'react'
+import {
+ 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'
+import { useChatInputAreaStyles } from './ChatInputArea.styles'
+
+// ---------------------------------------------------------------------------
+// Reusable status banner
+// ---------------------------------------------------------------------------
+
+interface StatusBannerProps {
+ icon: React.ReactElement
+ text: string
+ buttonText?: string
+ buttonIcon?: React.ReactElement
+ onButtonClick?: () => void
+ testId: string
+ className: string
+ textClassName: string
+ buttonTestId?: string
+}
+
+function StatusBanner({ icon, text, buttonText, buttonIcon, onButtonClick, testId, className, textClassName, buttonTestId }: StatusBannerProps) {
+ return (
+
+ {icon}
+
+ {text}
+
+ {onButtonClick && buttonText && (
+
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// 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 = useChatInputAreaStyles()
+ 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.)
+ // useLayoutEffect fires before paint, avoiding visible flicker on resize.
+ useLayoutEffect(() => {
+ 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 ? (
+
}
+ text="No target selected"
+ buttonText={onConfigureTarget ? "Configure Target" : undefined}
+ buttonIcon={
}
+ onButtonClick={onConfigureTarget}
+ testId="no-target-banner"
+ buttonTestId="configure-target-input-btn"
+ />
+ ) : operatorLocked ? (
+
}
+ text={`This conversation belongs to operator: ${attackOperator}.`}
+ buttonText={onUseAsTemplate ? "Continue with your target" : undefined}
+ buttonIcon={
}
+ onButtonClick={onUseAsTemplate}
+ testId="operator-locked-banner"
+ buttonTestId="use-as-template-btn"
+ />
+ ) : crossTargetLocked ? (
+
}
+ text="This attack uses a different target. Continue with your target to keep the conversation."
+ buttonText={onUseAsTemplate ? "Continue with your target" : undefined}
+ buttonIcon={
}
+ onButtonClick={onUseAsTemplate}
+ testId="cross-target-banner"
+ buttonTestId="use-as-template-btn"
+ />
+ ) : singleTurnLimitReached ? (
+
}
+ text="This target only supports single-turn conversations."
+ buttonText={onNewConversation ? "New Conversation" : undefined}
+ buttonIcon={
}
+ onButtonClick={onNewConversation}
+ testId="single-turn-banner"
+ buttonTestId="new-conversation-btn"
+ />
+ ) : (
+ <>
+
+
+ {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.styles.ts b/frontend/src/components/Chat/ChatWindow.styles.ts
new file mode 100644
index 0000000000..24183e8d2f
--- /dev/null
+++ b/frontend/src/components/Chat/ChatWindow.styles.ts
@@ -0,0 +1,51 @@
+import { makeStyles, tokens } from '@fluentui/react-components'
+
+export const useChatWindowStyles = makeStyles({
+ root: {
+ display: 'flex',
+ height: '100%',
+ width: '100%',
+ overflow: 'hidden',
+ },
+ chatArea: {
+ display: 'flex',
+ flexDirection: 'column',
+ flex: 1,
+ minWidth: 0,
+ backgroundColor: tokens.colorNeutralBackground2,
+ overflow: 'hidden',
+ },
+ ribbon: {
+ height: '48px',
+ minHeight: '48px',
+ flexShrink: 0,
+ backgroundColor: tokens.colorNeutralBackground3,
+ borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'space-between',
+ padding: `0 ${tokens.spacingHorizontalL}`,
+ gap: tokens.spacingHorizontalM,
+ },
+ conversationInfo: {
+ display: 'flex',
+ alignItems: 'center',
+ gap: tokens.spacingHorizontalS,
+ 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,
+ },
+})
diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx
index 41f2d6d489..ec446d81f0 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,29 @@ describe("ChatWindow Integration", () => {
];
const defaultProps = {
- messages: [],
- 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(),
+ 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,66 +254,405 @@ 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();
});
- it("should display existing messages", () => {
+ it("should display existing messages", async () => {
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(mockMessages);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeInTheDocument();
+ expect(screen.getByText("Hi there!")).toBeInTheDocument();
+ });
+ });
+
+ 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(
-
+
);
- expect(screen.getByText("Hello")).toBeInTheDocument();
- expect(screen.getByText("Hi there!")).toBeInTheDocument();
+ // Banner in ChatInputArea area
+ expect(screen.getByTestId("no-target-banner")).toBeInTheDocument();
+ expect(screen.getByTestId("configure-target-input-btn")).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 call onNewAttack when New Attack button is clicked", async () => {
+ const user = userEvent.setup();
+ const onNewAttack = jest.fn();
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([]);
+
+ 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
+ // -----------------------------------------------------------------------
+
+ it("should display target without model name", () => {
+ const targetNoModel: TargetInstance = {
+ ...mockTarget,
+ model_name: null,
+ };
+
+ render(
+
+
+
+ );
- expect(onNewChat).toHaveBeenCalled();
+ 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 });
- const onSendMessage = jest.fn();
+ // -----------------------------------------------------------------------
+ // First message → create attack + send
+ // -----------------------------------------------------------------------
+
+ it("should create attack and send text message on first message", async () => {
+ const user = userEvent.setup();
+ 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: "user",
+ content: "Hello",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ {
+ role: "assistant",
+ content: "Hello back!",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
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(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" },
+ });
+ });
+
+ // Messages should appear in the DOM
+ await waitFor(() => {
+ expect(screen.getByText("Hello back!")).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // 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)
+ );
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Error handling
+ // -----------------------------------------------------------------------
+
+ it("should show error message when API call fails", async () => {
+ const user = userEvent.setup();
+
+ 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, "test");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ await waitFor(() => {
+ expect(screen.getByText(/Network error/)).toBeInTheDocument();
+ });
+ });
+
+ it("should show error message when addMessage fails", async () => {
+ const user = userEvent.setup();
+
+ 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(screen.getByText(/Request failed with status code 404/)).toBeInTheDocument();
+ });
+ });
+
+ it("should extract detail from axios-style error response", async () => {
+ const user = userEvent.setup();
+
+ 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(screen.getByText(/Failed to add message/)).toBeInTheDocument();
+ });
+ });
+
+ it("should extract plain string from axios-style error response", async () => {
+ const user = userEvent.setup();
+
+ 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(screen.getByText(/Internal Server Error/)).toBeInTheDocument();
+ });
+ });
+
+ it("should show generic error for non-Error thrown values", async () => {
+ const user = userEvent.setup();
+
+ 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(screen.getByText(/string error/)).toBeInTheDocument();
+ });
});
- it("should call onReceiveMessage after sending", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
- const onReceiveMessage = jest.fn();
+ // -----------------------------------------------------------------------
+ // Loading indicator flow
+ // -----------------------------------------------------------------------
+
+ it("should show loading then replace with response", async () => {
+ const user = userEvent.setup();
+
+ mockedMapper.buildMessagePieces.mockResolvedValue([
+ { data_type: "text", original_value: "Hello" },
+ ]);
+ mockedAttacksApi.addMessage.mockResolvedValue(makeTextResponse("Hi!") as never);
+ mockedMapper.backendMessagesToFrontend.mockReturnValue([
+ {
+ role: "user",
+ content: "Hello",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ {
+ role: "assistant",
+ content: "Hi!",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ ]);
render(
-
+
);
@@ -112,44 +660,1489 @@ describe("ChatWindow Integration", () => {
await user.type(input, "Hello");
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);
+ // Response should appear in the DOM
+ await waitFor(() => {
+ expect(screen.getByText("Hi!")).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: image response
+ // -----------------------------------------------------------------------
+
+ it("should handle image response from backend", async () => {
+ const user = userEvent.setup();
+
+ 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 }));
+
+ // The response should include the image attachment rendered in the DOM
+ await waitFor(() => {
+ expect(screen.getByRole("img")).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: audio response
+ // -----------------------------------------------------------------------
+
+ it("should handle audio response from backend", async () => {
+ const user = userEvent.setup();
+
+ 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 }));
+
+ // Audio element should appear in the DOM
+ await waitFor(() => {
+ const audioEl = document.querySelector("audio");
+ expect(audioEl).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: video response
+ // -----------------------------------------------------------------------
+
+ it("should handle video response from backend", async () => {
+ const user = userEvent.setup();
+
+ 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, "Create a video");
+ await user.click(screen.getByRole("button", { name: /send/i }));
+
+ // Video element should appear in the DOM
+ await waitFor(() => {
+ const videoEl = document.querySelector("video");
+ expect(videoEl).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-modal: mixed text + image response
+ // -----------------------------------------------------------------------
+
+ it("should handle mixed text + image response", async () => {
+ const user = userEvent.setup();
+
+ 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 }));
+
+ // Both text and image should appear in the DOM
+ await waitFor(() => {
+ expect(screen.getByText("Here is the result:")).toBeInTheDocument();
+ expect(screen.getByRole("img")).toBeInTheDocument();
});
+ });
+
+ // -----------------------------------------------------------------------
+ // Sending image attachment
+ // -----------------------------------------------------------------------
+
+ it("should send image attachment alongside text", async () => {
+ const user = userEvent.setup();
+
+ 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(onReceiveMessage).toHaveBeenCalledWith(
+ expect(mockedAttacksApi.addMessage).toHaveBeenCalledWith(
+ "ar-conv-attach",
expect.objectContaining({
- role: "assistant",
- content: "Echo: Hello",
+ 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",
})
);
});
});
- it("should disable input while sending", async () => {
- const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
+ // -----------------------------------------------------------------------
+ // 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, "Test");
+ await user.type(input, "Listen");
await user.click(screen.getByRole("button", { name: /send/i }));
- // Input should be disabled while waiting for response
- expect(input).toBeDisabled();
-
- // Advance timers to complete the send (wrapped in act)
- await act(async () => {
- jest.advanceTimersByTime(600);
+ 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();
+
+ 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(screen.getByText(/Content was filtered by safety system/)).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // Multi-turn conversation
+ // -----------------------------------------------------------------------
+
+ it("should support multi-turn: create on first, reuse on second", async () => {
+ const user = userEvent.setup();
+ const onConversationCreated = 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: "user",
+ content: "Turn 1",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ {
+ 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: "user",
+ content: "Turn 1",
+ timestamp: "2026-01-01T00:00:00Z",
+ },
+ {
+ role: "assistant",
+ content: "Reply 1",
+ timestamp: "2026-01-01T00:00:01Z",
+ },
+ {
+ role: "user",
+ content: "Turn 2",
+ timestamp: "2026-01-01T00:00:02Z",
+ },
+ {
+ role: "assistant",
+ content: "Reply 2",
+ timestamp: "2026-01-01T00:00:03Z",
+ },
+ ]);
+
+ 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", async () => {
+ 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" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithUser);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ 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", async () => {
+ const messagesWithUser: Message[] = [
+ { role: "user", content: "Hello", timestamp: "2026-01-01T00:00:00Z" },
+ { role: "assistant", content: "Hi there", timestamp: "2026-01-01T00:00:01Z" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithUser);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByText("Hello")).toBeInTheDocument();
+ });
+ 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", async () => {
+ 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" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithUser);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ 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",
+ });
+
+ 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" },
+ ];
+
+ mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
+ mockedMapper.backendMessagesToFrontend.mockReturnValue(messagesWithUser);
+
+ 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 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 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");
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // 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 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();
+
+ 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(screen.getByText(/Network error/)).toBeInTheDocument();
+ });
+ });
+
+ it("should show timeout error when addMessage fails with timeout", async () => {
+ const user = userEvent.setup();
+
+ 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(screen.getByText(/timed out/)).toBeInTheDocument();
+ });
+ });
+
+ // -----------------------------------------------------------------------
+ // 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..e7e57cbd67 100644
--- a/frontend/src/components/Chat/ChatWindow.tsx
+++ b/frontend/src/components/Chat/ChatWindow.tsx
@@ -1,62 +1,162 @@
-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'
-
-const useStyles = makeStyles({
- root: {
- display: 'flex',
- flexDirection: 'column',
- height: '100%',
- width: '100%',
- backgroundColor: tokens.colorNeutralBackground2,
- overflow: 'hidden',
- },
- ribbon: {
- height: '48px',
- minHeight: '48px',
- flexShrink: 0,
- backgroundColor: tokens.colorNeutralBackground3,
- borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
- display: 'flex',
- alignItems: 'center',
- justifyContent: 'space-between',
- padding: `0 ${tokens.spacingHorizontalL}`,
- gap: tokens.spacingHorizontalM,
- },
- conversationInfo: {
- display: 'flex',
- alignItems: 'center',
- gap: tokens.spacingHorizontalS,
- color: tokens.colorNeutralForeground2,
- fontSize: tokens.fontSizeBase300,
- },
-})
+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'
+import { useChatWindowStyles } from './ChatWindow.styles'
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
+ 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,
+ labels,
+ onLabelsChange,
+ onNavigate,
+ attackLabels,
+ attackTarget,
+ isLoadingAttack,
+ relatedConversationCount,
}: ChatWindowProps) {
- const styles = useStyles()
- const [isSending, setIsSending] = useState(false)
+ const styles = useChatWindowStyles()
+ const [messages, setMessages] = useState([])
+ // 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