diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index d951b0c0e7..3a13fdb8a0 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -6,6 +6,8 @@ import { makeTarget } from "./_targets"; // --------------------------------------------------------------------------- const MOCK_CONVERSATION_ID = "e2e-conv-001"; +const WIDE_IMAGE_DATA_URI = + "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='800' height='600' viewBox='0 0 800 600'%3E%3Crect width='800' height='600' fill='%230078d4'/%3E%3C/svg%3E"; /** Intercept targets & attacks APIs so the chat flow can run without real keys. */ async function mockBackendAPIs(page: Page) { @@ -210,6 +212,62 @@ test.describe("Chat Functionality", () => { await expect(badge).toContainText(/gpt-4o-mock/); }); + test("should overlay conversations without shrinking mobile chat and restore focus", async ({ page }) => { + await page.route(/\/api\/attacks\/[^/]+\/conversations/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + attack_result_id: "e2e-attack-001", + main_conversation_id: MOCK_CONVERSATION_ID, + conversations: [ + { + conversation_id: MOCK_CONVERSATION_ID, + message_count: 2, + last_message_preview: "Mobile drawer regression", + created_at: "2026-01-01T00:00:00Z", + }, + ], + }), + }); + }); + + const input = page.getByRole("textbox"); + await input.fill("Start a mobile conversation"); + await page.getByRole("button", { name: /send/i }).click(); + await expect(page.getByText("Start a mobile conversation", { exact: true })).toBeVisible(); + + await page.setViewportSize({ width: 390, height: 844 }); + const chatArea = page.getByTestId("chat-area"); + const toggleButton = page.getByRole("button", { name: "Toggle conversations panel" }); + await expect(toggleButton).toBeEnabled(); + const beforeOpen = await chatArea.boundingBox(); + + await toggleButton.click(); + const drawer = page.getByRole("dialog", { name: "Attack Conversations" }); + await expect(drawer).toBeVisible(); + + const afterOpen = await chatArea.boundingBox(); + const viewport = page.viewportSize(); + if (!beforeOpen || !afterOpen || !viewport) { + throw new Error("Expected chat and drawer layout bounds"); + } + + expect(Math.abs(afterOpen.width - beforeOpen.width)).toBeLessThanOrEqual(1); + await expect.poll(async () => { + const drawerBounds = await drawer.boundingBox(); + return drawerBounds ? drawerBounds.x : -1; + }).toBeGreaterThanOrEqual(0); + await expect.poll(async () => { + const drawerBounds = await drawer.boundingBox(); + return drawerBounds ? drawerBounds.x + drawerBounds.width : Number.POSITIVE_INFINITY; + }).toBeLessThanOrEqual(viewport.width); + + await page.keyboard.press("Escape"); + await expect(drawer).not.toBeVisible(); + await expect(toggleButton).toBeFocused(); + }); + test("should send a message and receive backend response", async ({ page }) => { const input = page.getByRole("textbox"); await expect(input).toBeEnabled(); @@ -428,8 +486,8 @@ test.describe("Multi-modal: Image response", () => { original_value_data_type: "text", converted_value_data_type: "image_path", original_value: "generated image", - converted_value: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAACklEQVR4nGMAAQAABQABDQottAAAAABJRU5ErkJggg==", - converted_value_mime_type: "image/png", + converted_value: WIDE_IMAGE_DATA_URI, + converted_value_mime_type: "image/svg+xml", scores: [], response_error: "none", }, @@ -439,6 +497,7 @@ test.describe("Multi-modal: Image response", () => { await setupImageMock(page); await page.goto("/"); await activateMockTarget(page); + await page.setViewportSize({ width: 390, height: 844 }); const input = page.getByRole("textbox"); await input.fill("Generate an image"); @@ -451,7 +510,71 @@ test.describe("Multi-modal: Image response", () => { const img = page.locator('img:not([alt="Co-PyRIT Logo"])'); await expect(img).toBeVisible({ timeout: 10000 }); const src = await img.getAttribute("src"); - expect(src).toContain("data:image/png;base64,"); + expect(src).toContain("data:image/svg+xml"); + + const bubble = page.locator('[data-testid^="message-bubble-"]', { has: img }); + const actions = bubble.locator('[data-testid^="message-actions-"]'); + await expect(bubble).toBeVisible(); + await expect(actions).toBeVisible(); + await expect(async () => { + const layoutBounds = await img.evaluate((image) => { + const bubbleElement = image.closest('[data-testid^="message-bubble-"]'); + const actionsElement = bubbleElement?.querySelector('[data-testid^="message-actions-"]'); + if (!bubbleElement || !actionsElement) { + return null; + } + + const imageRect = image.getBoundingClientRect(); + const bubbleRect = bubbleElement.getBoundingClientRect(); + const actionsRect = actionsElement.getBoundingClientRect(); + return { + image: { + x: imageRect.x, + width: imageRect.width, + height: imageRect.height, + }, + bubble: { + x: bubbleRect.x, + width: bubbleRect.width, + }, + actions: { + x: actionsRect.x, + width: actionsRect.width, + }, + }; + }); + if (!layoutBounds) { + throw new Error("Expected image message layout bounds"); + } + const { image: imageBounds, bubble: bubbleBounds, actions: actionBounds } = layoutBounds; + + expect(imageBounds.width).toBeGreaterThan(0); + expect(imageBounds.height).toBeGreaterThan(0); + expect(imageBounds.x).toBeGreaterThanOrEqual(bubbleBounds.x); + expect(imageBounds.x + imageBounds.width).toBeLessThanOrEqual( + bubbleBounds.x + bubbleBounds.width + 1, + ); + expect(Math.abs(imageBounds.width / imageBounds.height - 4 / 3)).toBeLessThan(0.02); + expect(actionBounds.x + actionBounds.width).toBeLessThanOrEqual( + bubbleBounds.x + bubbleBounds.width + 1, + ); + + const hasHorizontalOverflow = await page.getByTestId("message-list").evaluate( + (element) => element.scrollWidth > element.clientWidth + 1, + ); + expect(hasHorizontalOverflow).toBe(false); + }).toPass({ timeout: 10000 }); + + await page.setViewportSize({ width: 1024, height: 768 }); + await expect(async () => { + const desktopImageBounds = await img.boundingBox(); + if (!desktopImageBounds) { + throw new Error("Expected desktop image layout bounds"); + } + expect(desktopImageBounds.width).toBeGreaterThan(0); + expect(desktopImageBounds.height).toBeGreaterThan(0); + expect(desktopImageBounds.width).toBeLessThanOrEqual(400); + }).toPass({ timeout: 10000 }); }); }); @@ -473,6 +596,7 @@ test.describe("Multi-modal: Audio response", () => { await setupAudioMock(page); await page.goto("/"); await activateMockTarget(page); + await page.setViewportSize({ width: 390, height: 844 }); const input = page.getByRole("textbox"); await input.fill("Speak this out loud"); @@ -483,6 +607,38 @@ test.describe("Multi-modal: Audio response", () => { // Audio element should appear const audio = page.locator("audio"); await expect(audio).toBeVisible({ timeout: 10000 }); + + const audioLayout = await audio.evaluate((element) => { + const bubbleElement = element.closest('[data-testid^="message-bubble-"]'); + if (!bubbleElement) { + return null; + } + + const audioRect = element.getBoundingClientRect(); + const bubbleRect = bubbleElement.getBoundingClientRect(); + return { + audio: { + x: audioRect.x, + width: audioRect.width, + }, + bubble: { + x: bubbleRect.x, + width: bubbleRect.width, + }, + }; + }); + if (!audioLayout) { + throw new Error("Expected audio message layout bounds"); + } + + expect(audioLayout.audio.x).toBeGreaterThanOrEqual(audioLayout.bubble.x); + expect(audioLayout.audio.x + audioLayout.audio.width).toBeLessThanOrEqual( + audioLayout.bubble.x + audioLayout.bubble.width + 1, + ); + const hasHorizontalOverflow = await page.getByTestId("message-list").evaluate( + (element) => element.scrollWidth > element.clientWidth + 1, + ); + expect(hasHorizontalOverflow).toBe(false); }); }); diff --git a/frontend/src/components/Chat/ChatWindow.styles.ts b/frontend/src/components/Chat/ChatWindow.styles.ts index 504626d1f5..21a361c204 100644 --- a/frontend/src/components/Chat/ChatWindow.styles.ts +++ b/frontend/src/components/Chat/ChatWindow.styles.ts @@ -15,6 +15,16 @@ export const useChatWindowStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground2, overflow: 'hidden', }, + conversationDrawer: { + width: '280px', + minWidth: '280px', + height: '100%', + }, + narrowConversationDrawer: { + width: '320px', + minWidth: 0, + maxWidth: '100vw', + }, ribbon: { height: '48px', minHeight: '48px', diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index f705915964..c054e21fff 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -59,6 +59,19 @@ const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children, }) => {children}; +function mockMatchMedia(matchesNarrowScreen: boolean): void { + (window.matchMedia as jest.Mock).mockImplementation((query: string) => ({ + matches: matchesNarrowScreen && query === "(max-width: 600px)", + media: query, + onchange: null, + addListener: jest.fn(), + removeListener: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })); +} + const mockTarget: TargetInstance = makeTarget({ target_registry_name: "openai_chat_1", target_type: "OpenAIChatTarget", @@ -262,6 +275,7 @@ describe("ChatWindow Integration", () => { beforeEach(() => { jest.clearAllMocks(); + mockMatchMedia(false); // Default: panel API returns empty conversations mockedAttacksApi.getConversations.mockResolvedValue({ conversations: [], @@ -1709,6 +1723,12 @@ describe("ChatWindow Integration", () => { await waitFor(() => { expect(screen.getByTestId("conversation-panel")).toBeInTheDocument(); }); + expect( + screen.getByRole("complementary", { name: "Attack Conversations" }) + ).toBeInTheDocument(); + expect( + screen.queryByRole("dialog", { name: "Attack Conversations" }) + ).not.toBeInTheDocument(); }); it("should not auto-open conversation panel when relatedConversationCount is 0", () => { @@ -1770,6 +1790,61 @@ describe("ChatWindow Integration", () => { }); }); + it("should keep the mobile drawer closed until requested and restore focus after Escape", async () => { + const user = userEvent.setup(); + mockMatchMedia(true); + mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] }); + mockedAttacksApi.getConversations.mockResolvedValue({ + main_conversation_id: "conv-mobile", + conversations: [ + { + conversation_id: "conv-mobile", + is_main: true, + message_count: 1, + created_at: "2026-01-01T00:00:00Z", + }, + ], + }); + mockedMapper.backendMessagesToFrontend.mockReturnValue([]); + + render( + + + + ); + + const toggleButton = screen.getByRole("button", { + name: "Toggle conversations panel", + }); + expect( + screen.queryByRole("dialog", { name: "Attack Conversations" }) + ).not.toBeInTheDocument(); + expect(toggleButton).toHaveAttribute("aria-expanded", "false"); + + await user.click(toggleButton); + + expect( + await screen.findByRole("dialog", { name: "Attack Conversations" }) + ).toBeInTheDocument(); + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + + await user.keyboard("{Escape}"); + + await waitFor(() => { + expect( + screen.queryByRole("dialog", { name: "Attack Conversations" }) + ).not.toBeInTheDocument(); + }); + expect(toggleButton).toHaveAttribute("aria-expanded", "false"); + expect(toggleButton).toHaveFocus(); + }); + it("should open conversation panel when copying to new conversation", async () => { const mockMessages: Message[] = [ { role: "user", content: "hello", data_type: "text" }, diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index 73d257d8e2..51a47b717a 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,8 +1,12 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' import { Button, + Drawer, + mergeClasses, Text, Tooltip, + useRestoreFocusSource, + useRestoreFocusTarget, } from '@fluentui/react-components' import { AddRegular, PanelRightRegular } from '@fluentui/react-icons' import MessageList from './MessageList' @@ -23,6 +27,14 @@ import { targetEndpoint, targetModelName, targetType } from '../../utils/targetI import type { ViewName } from '../Sidebar/Navigation' import { useChatWindowStyles } from './ChatWindow.styles' +const NARROW_SCREEN_QUERY = '(max-width: 600px)' + +function matchesNarrowScreen(): boolean { + return typeof window !== 'undefined' + && typeof window.matchMedia === 'function' + && window.matchMedia(NARROW_SCREEN_QUERY).matches +} + interface ChatWindowProps { onNewAttack: () => void activeTarget: TargetInstance | null @@ -61,6 +73,8 @@ export default function ChatWindow({ relatedConversationCount, }: ChatWindowProps) { const styles = useChatWindowStyles() + const restoreFocusTargetAttributes = useRestoreFocusTarget() + const restoreFocusSourceAttributes = useRestoreFocusSource() const [messages, setMessages] = useState([]) // Track sending state per conversation so parallel conversations can send independently const [sendingConversations, setSendingConversations] = useState>(new Set()) @@ -70,6 +84,7 @@ export default function ChatWindow({ const [loadedConversationId, setLoadedConversationId] = useState(null) const isSending = activeConversationId ? sendingConversations.has(activeConversationId) : Boolean(sendingConversations.size) const [isPanelOpen, setIsPanelOpen] = useState(false) + const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) const [chatInputText, setChatInputText] = useState('') const [systemPrompt, setSystemPrompt] = useState('') @@ -79,6 +94,19 @@ export default function ChatWindow({ const [panelRefreshKey, setPanelRefreshKey] = useState(0) const inputBoxRef = useRef(null) + useEffect(() => { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { + return + } + + const mediaQuery = window.matchMedia(NARROW_SCREEN_QUERY) + const handleChange = (event: MediaQueryListEvent) => { + setIsNarrowScreen(event.matches) + } + mediaQuery.addEventListener('change', handleChange) + return () => mediaQuery.removeEventListener('change', handleChange) + }, []) + const handleAttachmentsChange = useCallback((types: string[], data: Record) => { setAttachmentTypes(types) setAttachmentData(data) @@ -119,7 +147,9 @@ export default function ChatWindow({ && relatedConversationCount > 0 ) { setAutoOpenedForAttack(attackResultId) - setIsPanelOpen(true) + if (!isNarrowScreen) { + setIsPanelOpen(true) + } } // Set by panel click to bypass the in-flight guard on the next useEffect cycle. // This lets users switch to a sending conversation while still protecting @@ -220,10 +250,13 @@ export default function ChatWindow({ const handlePanelSelectConversation = useCallback((convId: string) => { forceLoadRef.current = true onSelectConversation(convId) + if (isNarrowScreen) { + setIsPanelOpen(false) + } if (convId === activeConversationId && attackResultId) { loadConversation(attackResultId, convId) } - }, [attackResultId, activeConversationId, onSelectConversation, loadConversation]) + }, [attackResultId, activeConversationId, isNarrowScreen, onSelectConversation, loadConversation]) const handleSend = async (originalValue: string, convertedValue: string | undefined, attachments: MessageAttachment[]) => { if (!activeTarget) { return } @@ -429,11 +462,11 @@ export default function ChatWindow({ try { const response = await attacksApi.createConversation(attackResultId, {}) onSelectConversation(response.conversation_id) - setIsPanelOpen(true) + setIsPanelOpen(!isNarrowScreen) } catch { // Silently fail } - }, [attackResultId, onSelectConversation]) + }, [attackResultId, isNarrowScreen, onSelectConversation]) // ------------------------------------------------------------------- // Message action handlers (4 buttons on each assistant message) @@ -460,7 +493,7 @@ export default function ChatWindow({ try { const response = await attacksApi.createConversation(attackResultId, {}) onSelectConversation(response.conversation_id) - setIsPanelOpen(true) + setIsPanelOpen(!isNarrowScreen) // Small delay so the panel/messages update first setTimeout(() => { if (msg.content) inputBoxRef.current?.setText(msg.content) @@ -474,7 +507,7 @@ export default function ChatWindow({ // If creating fails, fall back to current conversation if (msg.content) inputBoxRef.current?.setText(msg.content) } - }, [attackResultId, messages, onSelectConversation]) + }, [attackResultId, isNarrowScreen, messages, onSelectConversation]) /** 3. Branch into a new conversation within the same attack (clone up to clicked message) */ const handleBranchConversation = useCallback(async (messageIndex: number) => { @@ -486,7 +519,7 @@ export default function ChatWindow({ cutoff_index: messageIndex, }) onSelectConversation(response.conversation_id) - setIsPanelOpen(true) + setIsPanelOpen(!isNarrowScreen) // Load the cloned messages const messagesResp = await attacksApi.getMessages(attackResultId, response.conversation_id) const frontendMessages = backendMessagesToFrontend(messagesResp.messages) @@ -494,7 +527,7 @@ export default function ChatWindow({ } catch (err) { console.error('Failed to branch into new conversation:', err) } - }, [attackResultId, activeConversationId, onSelectConversation]) + }, [attackResultId, activeConversationId, isNarrowScreen, onSelectConversation]) /** 4. Branch into a brand-new attack (clone up to clicked message with new labels) */ const handleBranchAttack = useCallback(async (messageIndex: number) => { @@ -595,7 +628,7 @@ export default function ChatWindow({ }} /> )} -
+
{activeTarget ? ( @@ -612,12 +645,15 @@ export default function ChatWindow({
- {isPanelOpen && ( + setIsPanelOpen(open)} + className={mergeClasses( + styles.conversationDrawer, + isNarrowScreen && styles.narrowConversationDrawer, + )} + aria-label="Attack Conversations" + > - )} +
) } diff --git a/frontend/src/components/Chat/ConversationPanel.styles.ts b/frontend/src/components/Chat/ConversationPanel.styles.ts index d821b33580..707b955ccc 100644 --- a/frontend/src/components/Chat/ConversationPanel.styles.ts +++ b/frontend/src/components/Chat/ConversationPanel.styles.ts @@ -5,9 +5,8 @@ export const useConversationPanelStyles = makeStyles({ display: 'flex', flexDirection: 'column', height: '100%', - width: '280px', - minWidth: '280px', - borderLeft: `1px solid ${tokens.colorNeutralStroke1}`, + width: '100%', + minWidth: 0, backgroundColor: tokens.colorNeutralBackground3, overflow: 'hidden', }, diff --git a/frontend/src/components/Chat/ConversationPanel.tsx b/frontend/src/components/Chat/ConversationPanel.tsx index a49b9e3c32..3ef5d31706 100644 --- a/frontend/src/components/Chat/ConversationPanel.tsx +++ b/frontend/src/components/Chat/ConversationPanel.tsx @@ -112,7 +112,7 @@ export default function ConversationPanel({ // Actually, we'll handle refresh via the attackConversationId dependency return ( -
+
diff --git a/frontend/src/components/Chat/MessageList.styles.ts b/frontend/src/components/Chat/MessageList.styles.ts index 278b7732c9..6d2c44480d 100644 --- a/frontend/src/components/Chat/MessageList.styles.ts +++ b/frontend/src/components/Chat/MessageList.styles.ts @@ -3,6 +3,7 @@ import { makeStyles, tokens } from '@fluentui/react-components' export const useMessageListStyles = makeStyles({ root: { flex: 1, + minWidth: 0, overflowY: 'auto', padding: tokens.spacingVerticalXXL, display: 'flex', @@ -12,7 +13,8 @@ export const useMessageListStyles = makeStyles({ message: { display: 'flex', gap: tokens.spacingHorizontalM, - maxWidth: '800px', + minWidth: 0, + maxWidth: 'min(800px, 100%)', alignSelf: 'flex-start', }, userMessage: { @@ -25,6 +27,8 @@ export const useMessageListStyles = makeStyles({ padding: tokens.spacingVerticalM, borderRadius: tokens.borderRadiusMedium, flex: 1, + minWidth: 0, + maxWidth: '100%', }, userMessageContent: { backgroundColor: tokens.colorBrandBackground2, @@ -80,13 +84,20 @@ export const useMessageListStyles = makeStyles({ flexWrap: 'wrap', gap: tokens.spacingHorizontalS, marginTop: tokens.spacingVerticalS, + minWidth: 0, + maxWidth: '100%', + }, + attachmentItem: { + minWidth: 0, + maxWidth: '100%', }, imageContainer: { position: 'relative' as const, display: 'inline-block', - maxWidth: '400px', + width: 'fit-content', + maxWidth: 'min(400px, 100%)', maxHeight: '400px', - minWidth: '100px', + minWidth: 'min(100px, 100%)', minHeight: '60px', }, imageSpinner: { @@ -96,14 +107,17 @@ export const useMessageListStyles = makeStyles({ transform: 'translate(-50%, -50%)', }, attachmentPreview: { - maxWidth: '400px', + display: 'block', + width: 'auto', + height: 'auto', + maxWidth: '100%', maxHeight: '400px', borderRadius: tokens.borderRadiusMedium, objectFit: 'contain', border: `1px solid ${tokens.colorNeutralStroke1}`, }, attachmentPreviewHidden: { - maxWidth: '400px', + maxWidth: '100%', maxHeight: '400px', borderRadius: tokens.borderRadiusMedium, objectFit: 'contain', @@ -113,11 +127,18 @@ export const useMessageListStyles = makeStyles({ height: 0, }, videoPreview: { - maxWidth: '400px', + display: 'block', + width: 'auto', + height: 'auto', + maxWidth: 'min(400px, 100%)', maxHeight: '300px', borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, }, + audioPreview: { + width: '300px', + maxWidth: '100%', + }, attachmentFile: { display: 'flex', alignItems: 'center', @@ -126,6 +147,8 @@ export const useMessageListStyles = makeStyles({ backgroundColor: tokens.colorNeutralBackground1, borderRadius: tokens.borderRadiusMedium, border: `1px solid ${tokens.colorNeutralStroke1}`, + minWidth: 0, + maxWidth: '100%', }, attachmentFileName: { flex: 1, diff --git a/frontend/src/components/Chat/MessageList.tsx b/frontend/src/components/Chat/MessageList.tsx index f0e0a7d49e..64a2d4caa4 100644 --- a/frontend/src/components/Chat/MessageList.tsx +++ b/frontend/src/components/Chat/MessageList.tsx @@ -78,7 +78,7 @@ function MediaWithFallback({ type, src, className }: { type: 'video' | 'audio'; if (type === 'video') { return