diff --git a/frontend/e2e/routing.spec.ts b/frontend/e2e/routing.spec.ts index 019dca7b5c..eac5d0342c 100644 --- a/frontend/e2e/routing.spec.ts +++ b/frontend/e2e/routing.spec.ts @@ -74,6 +74,7 @@ const ATTACK_ROWS = [ makeAttackRow("atk-success", "success"), makeAttackRow("atk-failure", "failure"), ]; +const MARKDOWN_PREFERENCE_STORAGE_KEY = "pyrit.chatMarkdownMode"; /** Register every API mock the routing tests rely on. */ async function mockRoutingAPIs(page: Page) { @@ -239,4 +240,32 @@ test.describe("URL-driven routing", () => { await expect(page).toHaveURL(/\/history$/); await expect(page.getByTestId("attacks-table")).toBeVisible(); }); + + test("Markdown preference survives route remount and reload", async ({ page }) => { + await page.goto("/chat"); + + const markdownToggle = page.getByRole("switch", { name: "Markdown" }); + await expect(markdownToggle).not.toBeChecked(); + await markdownToggle.click(); + await expect(markdownToggle).toBeChecked(); + await expect + .poll(() => + page.evaluate( + (storageKey: string) => window.localStorage.getItem(storageKey), + MARKDOWN_PREFERENCE_STORAGE_KEY, + ), + ) + .toBe("markdown"); + + await page.getByTitle("Attack History").click(); + await expect(page).toHaveURL(/\/history$/); + await expect(page.getByTestId("attacks-table")).toBeVisible(); + + await page.getByTitle("Chat").click(); + await expect(page).toHaveURL(/\/chat$/); + await expect(page.getByRole("switch", { name: "Markdown" })).toBeChecked(); + + await page.reload(); + await expect(page.getByRole("switch", { name: "Markdown" })).toBeChecked(); + }); }); diff --git a/frontend/src/components/Chat/ChatWindow.test.tsx b/frontend/src/components/Chat/ChatWindow.test.tsx index 7eafd78ef6..3f28b75d01 100644 --- a/frontend/src/components/Chat/ChatWindow.test.tsx +++ b/frontend/src/components/Chat/ChatWindow.test.tsx @@ -54,6 +54,7 @@ jest.mock("../../utils/messageMapper", () => ({ const mockedAttacksApi = attacksApi as jest.Mocked; const mockedConvertersApi = convertersApi as jest.Mocked; const mockedMapper = messageMapper as jest.Mocked; +const MARKDOWN_PREFERENCE_STORAGE_KEY = "pyrit.chatMarkdownMode"; const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children, @@ -275,6 +276,7 @@ describe("ChatWindow Integration", () => { beforeEach(() => { jest.clearAllMocks(); + window.localStorage.clear(); mockMatchMedia(false); // Default: panel API returns empty conversations mockedAttacksApi.getConversations.mockResolvedValue({ @@ -292,6 +294,10 @@ describe("ChatWindow Integration", () => { }); }); + afterEach(() => { + jest.restoreAllMocks(); + }); + // ----------------------------------------------------------------------- // Basic rendering // ----------------------------------------------------------------------- @@ -312,19 +318,108 @@ describe("ChatWindow Integration", () => { expect(screen.getByRole("textbox")).toBeInTheDocument(); }); - it("renders a global markdown switch that toggles on click", async () => { - const user = userEvent.setup(); + it("defaults to raw mode when no Markdown preference is stored", () => { render( ); + expect(screen.getByRole("switch", { name: /markdown/i })).not.toBeChecked(); + expect(window.localStorage.getItem(MARKDOWN_PREFERENCE_STORAGE_KEY)).toBeNull(); + }); + + it("persists explicit Markdown and raw choices across remounts", async () => { + const user = userEvent.setup(); + const firstRender = render( + + + + ); + const toggle = screen.getByRole("switch", { name: /markdown/i }); expect(toggle).not.toBeChecked(); await user.click(toggle); expect(toggle).toBeChecked(); + expect(window.localStorage.getItem(MARKDOWN_PREFERENCE_STORAGE_KEY)).toBe("markdown"); + + firstRender.unmount(); + const secondRender = render( + + + + ); + const remountedToggle = screen.getByRole("switch", { name: /markdown/i }); + expect(remountedToggle).toBeChecked(); + + await user.click(remountedToggle); + expect(remountedToggle).not.toBeChecked(); + expect(window.localStorage.getItem(MARKDOWN_PREFERENCE_STORAGE_KEY)).toBe("raw"); + + secondRender.unmount(); + render( + + + + ); + expect(screen.getByRole("switch", { name: /markdown/i })).not.toBeChecked(); + }); + + it("initializes Markdown mode from stored preference", () => { + window.localStorage.setItem(MARKDOWN_PREFERENCE_STORAGE_KEY, "markdown"); + + render( + + + + ); + + expect(screen.getByRole("switch", { name: /markdown/i })).toBeChecked(); + }); + + it("falls back to raw mode for an invalid stored preference", () => { + window.localStorage.setItem(MARKDOWN_PREFERENCE_STORAGE_KEY, "invalid"); + + render( + + + + ); + + expect(screen.getByRole("switch", { name: /markdown/i })).not.toBeChecked(); + }); + + it("falls back to raw mode when localStorage is unavailable during initialization", () => { + jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => { + throw new DOMException("Access denied", "SecurityError"); + }); + + expect(() => { + render( + + + + ); + }).not.toThrow(); + expect(screen.getByRole("switch", { name: /markdown/i })).not.toBeChecked(); + }); + + it("keeps the in-memory choice when localStorage is unavailable during persistence", async () => { + const user = userEvent.setup(); + jest.spyOn(Storage.prototype, "setItem").mockImplementation(() => { + throw new DOMException("Quota exceeded", "QuotaExceededError"); + }); + + render( + + + + ); + + const toggle = screen.getByRole("switch", { name: /markdown/i }); + await user.click(toggle); + expect(toggle).toBeChecked(); }); it("should display existing messages", async () => { diff --git a/frontend/src/components/Chat/ChatWindow.tsx b/frontend/src/components/Chat/ChatWindow.tsx index f7428b4af5..ec9c963336 100644 --- a/frontend/src/components/Chat/ChatWindow.tsx +++ b/frontend/src/components/Chat/ChatWindow.tsx @@ -1,4 +1,5 @@ import { useState, useRef, useEffect, useCallback, useMemo } from 'react' +import type { ChangeEvent } from 'react' import { Button, Drawer, @@ -14,6 +15,7 @@ import { useRestoreFocusSource, useRestoreFocusTarget, } from '@fluentui/react-components' +import type { SwitchOnChangeData } from '@fluentui/react-components' import { AddRegular, ArrowDownloadRegular, PanelRightRegular } from '@fluentui/react-icons' import MessageList from './MessageList' import SystemPromptBanner from './SystemPromptBanner' @@ -36,6 +38,32 @@ import type { ViewName } from '../Sidebar/Navigation' import { useChatWindowStyles } from './ChatWindow.styles' const NARROW_SCREEN_QUERY = '(max-width: 600px)' +const MARKDOWN_PREFERENCE_STORAGE_KEY = 'pyrit.chatMarkdownMode' + +type MarkdownPreference = 'raw' | 'markdown' + +function isMarkdownPreference(value: unknown): value is MarkdownPreference { + return value === 'raw' || value === 'markdown' +} + +function readStoredMarkdownPreference(): boolean { + if (typeof window === 'undefined') return false + try { + const storedPreference = window.localStorage.getItem(MARKDOWN_PREFERENCE_STORAGE_KEY) + return isMarkdownPreference(storedPreference) && storedPreference === 'markdown' + } catch { + return false + } +} + +function persistMarkdownPreference(enabled: boolean): void { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(MARKDOWN_PREFERENCE_STORAGE_KEY, enabled ? 'markdown' : 'raw') + } catch { + /* localStorage may be unavailable (private mode, quota, sandboxed iframe). */ + } +} function matchesNarrowScreen(): boolean { return typeof window !== 'undefined' @@ -94,8 +122,8 @@ export default function ChatWindow({ const [isPanelOpen, setIsPanelOpen] = useState(false) const [isNarrowScreen, setIsNarrowScreen] = useState(matchesNarrowScreen) const [isConverterPanelOpen, setIsConverterPanelOpen] = useState(false) - // Conversation-wide default for rendering message text as Markdown. - const [globalMarkdown, setGlobalMarkdown] = useState(false) + // Conversation-wide preference for rendering message text as Markdown. + const [globalMarkdown, setGlobalMarkdown] = useState(() => readStoredMarkdownPreference()) const [chatInputText, setChatInputText] = useState('') const [systemPrompt, setSystemPrompt] = useState('') const [attachmentTypes, setAttachmentTypes] = useState([]) @@ -104,6 +132,14 @@ export default function ChatWindow({ const [panelRefreshKey, setPanelRefreshKey] = useState(0) const inputBoxRef = useRef(null) + const handleMarkdownChange = useCallback(( + _event: ChangeEvent, + data: SwitchOnChangeData, + ): void => { + setGlobalMarkdown(data.checked) + persistMarkdownPreference(data.checked) + }, []) + useEffect(() => { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') { return @@ -671,7 +707,7 @@ export default function ChatWindow({ setGlobalMarkdown(data.checked)} + onChange={handleMarkdownChange} label="Markdown" data-testid="global-markdown-toggle" />