Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions frontend/e2e/routing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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();
});
});
99 changes: 97 additions & 2 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ jest.mock("../../utils/messageMapper", () => ({
const mockedAttacksApi = attacksApi as jest.Mocked<typeof attacksApi>;
const mockedConvertersApi = convertersApi as jest.Mocked<typeof convertersApi>;
const mockedMapper = messageMapper as jest.Mocked<typeof messageMapper>;
const MARKDOWN_PREFERENCE_STORAGE_KEY = "pyrit.chatMarkdownMode";

const TestWrapper: React.FC<{ children: React.ReactNode }> = ({
children,
Expand Down Expand Up @@ -275,6 +276,7 @@ describe("ChatWindow Integration", () => {

beforeEach(() => {
jest.clearAllMocks();
window.localStorage.clear();
mockMatchMedia(false);
// Default: panel API returns empty conversations
mockedAttacksApi.getConversations.mockResolvedValue({
Expand All @@ -292,6 +294,10 @@ describe("ChatWindow Integration", () => {
});
});

afterEach(() => {
jest.restoreAllMocks();
});

// -----------------------------------------------------------------------
// Basic rendering
// -----------------------------------------------------------------------
Expand All @@ -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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);

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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);

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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);
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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);
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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);

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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);

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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);
}).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(
<TestWrapper>
<ChatWindow {...defaultProps} />
</TestWrapper>
);

const toggle = screen.getByRole("switch", { name: /markdown/i });
await user.click(toggle);
expect(toggle).toBeChecked();
});

it("should display existing messages", async () => {
Expand Down
42 changes: 39 additions & 3 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import type { ChangeEvent } from 'react'
import {
Button,
Drawer,
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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<string[]>([])
Expand All @@ -104,6 +132,14 @@ export default function ChatWindow({
const [panelRefreshKey, setPanelRefreshKey] = useState(0)
const inputBoxRef = useRef<ChatInputAreaHandle>(null)

const handleMarkdownChange = useCallback((
_event: ChangeEvent<HTMLInputElement>,
data: SwitchOnChangeData,
): void => {
setGlobalMarkdown(data.checked)
persistMarkdownPreference(data.checked)
}, [])

useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
return
Expand Down Expand Up @@ -671,7 +707,7 @@ export default function ChatWindow({
<Tooltip content="Render all messages as Markdown by default" relationship="label">
<Switch
checked={globalMarkdown}
onChange={(_ev, data) => setGlobalMarkdown(data.checked)}
onChange={handleMarkdownChange}
label="Markdown"
data-testid="global-markdown-toggle"
/>
Expand Down