Skip to content
Merged
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
12 changes: 12 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ Your choice is saved in this browser. System follows the operating system's
light/dark preference. High-contrast mode overrides every palette and hides
decorations without forgetting the selected preset.

## Conversation selection

Opening a saved attack with related conversations automatically opens the
desktop conversation drawer. This default applies once per attack; later send
refreshes do not override your open/closed choice. Creating, copying, or repeating
conversations still opens the desktop drawer. On narrow screens the drawer opens
only when requested and closes after selecting a conversation.

## Resuming a scenario run

Failed runs offer **Resume run** on the run page and **Resume** in Scanner History.
Expand Down Expand Up @@ -161,6 +169,10 @@ E2E flow tests run in two modes controlled by Playwright projects and an environ

- **Live** (`--project live`, requires `E2E_LIVE_MODE=true`): Messages are sent to real OpenAI endpoints with `send: true`. Each target variant requires endpoint and model environment variables plus either an API key or an Azure endpoint accessible through the current Entra identity. Variants without a usable configuration are automatically skipped. Tests verify that real target responses render correctly.

Conversation-selection helpers check the toggle's `aria-expanded` state, not
drawer visibility during an animation. Use accessible row clicks and wait for
the destination URL and transcript before asserting its contents.

```bash
# Seeded integration (no credentials needed)
npx playwright test --project seeded
Expand Down
158 changes: 155 additions & 3 deletions frontend/e2e/chat-recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { compatibilityHeaders } from "./_compatibility";
import type {
AddMessageRequest,
AddMessageResponse,
AttackSummary,
ConversationMessagesResponse,
CreateAttackResponse,
CreateConversationResponse,
Expand Down Expand Up @@ -162,20 +163,42 @@ async function sendFromComposer(page: Page, text?: string): Promise<AddMessageRe
return readMessageSendResult(page.request, accepted);
}

async function createConversation(request: APIRequestContext, attackId: string): Promise<string> {
async function createConversation(
request: APIRequestContext, attackId: string, message?: string,
): Promise<string> {
const response = await request.post(`/api/attacks/${attackId}/conversations`, {
data: {}, headers: compatibilityHeaders(),
});
expect(response.status()).toBe(201);
const created: CreateConversationResponse = await response.json();
if (message !== undefined) {
const stored = await request.post(`/api/attacks/${attackId}/messages`, {
headers: compatibilityHeaders(),
data: {
role: "user",
pieces: [{ data_type: "text", original_value: message }],
send: false,
target_conversation_id: created.conversation_id,
},
});
expect(stored.ok()).toBeTruthy();
}
return created.conversation_id;
}

async function selectConversation(page: Page, conversationId: string): Promise<void> {
if (!await page.getByTestId("conversation-panel").isVisible()) {
const toggle = page.getByTestId("toggle-panel-btn");
await expect(toggle).toBeEnabled();
if (await toggle.getAttribute("aria-expanded") === "false") {
await page.getByRole("button", { name: "Toggle conversations panel", exact: true }).click();
}
await page.getByRole("button", { name: `Select conversation ${conversationId}`, exact: true }).click();
await expect(toggle).toHaveAttribute("aria-expanded", "true");
const conversation = page.getByRole("button", { name: `Select conversation ${conversationId}`, exact: true });
await conversation.click();
await expect(page).toHaveURL((url: URL) => url.pathname.endsWith(`/conversations/${conversationId}`));
if (await toggle.getAttribute("aria-expanded") === "true") {
await expect(conversation).toHaveAttribute("aria-current", "true");
}
}

async function installDeferredFileReads(page: Page): Promise<void> {
Expand Down Expand Up @@ -220,6 +243,135 @@ test.describe("Chat processing recovery @seeded", () => {
await page.getByTitle("Chat", { exact: true }).click();
});

test("keeps the drawer closed when a delayed send refresh discovers another conversation", async ({
page, request, localTarget,
}) => {
let releaseRefresh: () => void = () => {};
const refreshGate = new Promise<void>((resolve) => { releaseRefresh = resolve; });
let refreshStarted = false;
await page.route(/\/api\/attacks\/[^/?]+$/, async (route: Route) => {
if (route.request().method() === "GET") {
refreshStarted = true;
await refreshGate;
}
await route.continue();
});
try {
const first = await sendFromComposer(page, "Original conversation history");
const attackId = first.attack.attack_result_id;
await expect.poll(() => refreshStarted).toBe(true);
const otherId = await createConversation(request, attackId, "Selected conversation history");
const toggle = page.getByTestId("toggle-panel-btn");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(page.getByRole("button", { name: "Export conversation", exact: true })).toBeDisabled();
const refreshed = page.waitForResponse((response) => response.request().method() === "GET"
&& new URL(response.url()).pathname === `/api/attacks/${attackId}`);
releaseRefresh();
const response = await refreshed;
expect(response.ok()).toBeTruthy();
const attack: AttackSummary = await response.json();
expect(attack.related_conversations).toEqual(expect.arrayContaining([
expect.objectContaining({ conversation_id: otherId, conversation_type: "pruned" }),
]));
await expect(page.getByRole("button", { name: "Export conversation", exact: true })).toBeEnabled();
await expect(toggle).toHaveAttribute("aria-expanded", "false");

await selectConversation(page, otherId);
const transcript = page.getByTestId("message-list");
await expect(transcript.getByText("Selected conversation history", { exact: true })).toBeVisible();
await expect(transcript.getByText("Original conversation history", { exact: true })).toHaveCount(0);
expect(localTarget.requestBodies).toHaveLength(1);
} finally {
releaseRefresh();
}
});

for (const viewport of [
{ name: "desktop", width: 1280, height: 720 },
{ name: "narrow", width: 390, height: 844 },
]) {
test.describe(`${viewport.name} conversation selection`, () => {
test.use({ viewport: { width: viewport.width, height: viewport.height } });

test("preserves historical drawer defaults after delayed metadata", async ({
page, request, localTarget,
}) => {
const first = await sendFromComposer(page, "Original conversation history");
await expect(page.getByRole("button", { name: "Export conversation", exact: true })).toBeEnabled();
const attackId = first.attack.attack_result_id;
const otherId = await createConversation(request, attackId, "Selected conversation history");
let releaseMetadata: () => void = () => {};
const metadataGate = new Promise<void>((resolve) => { releaseMetadata = resolve; });
let metadataStarted = false;
await page.route((url: URL) => url.pathname === `/api/attacks/${attackId}`, async (route: Route) => {
if (route.request().method() === "GET") {
metadataStarted = true;
await metadataGate;
}
await route.continue();
});
try {
await page.goto(`/attacks/${attackId}`);
await expect.poll(() => metadataStarted).toBe(true);
await expect(page.getByText("Loading conversation...", { exact: true })).toBeVisible();
const toggle = page.getByTestId("toggle-panel-btn");
await expect(toggle).toHaveAttribute("aria-expanded", "false");
await expect(toggle).toBeDisabled();
releaseMetadata();

const transcript = page.getByTestId("message-list");
await expect(transcript.getByText("Original conversation history", { exact: true })).toBeVisible();
await expect(toggle).toHaveAttribute("aria-expanded", viewport.name === "narrow" ? "false" : "true");
if (viewport.name === "narrow") {
await expect(page.getByRole("dialog", { name: "Attack Conversations", exact: true })).toHaveCount(0);
} else {
await expect(page.getByRole("complementary", { name: "Attack Conversations", exact: true })).toBeVisible();
await expect(page.getByRole("button", {
name: `Select conversation ${first.attack.conversation_id}`, exact: true,
})).toHaveAttribute("aria-current", "true");
}

await selectConversation(page, otherId);
await expect(transcript.getByText("Selected conversation history", { exact: true })).toBeVisible();
await expect(transcript.getByText("Original conversation history", { exact: true })).toHaveCount(0);
expect(localTarget.requestBodies).toHaveLength(1);
} finally {
releaseMetadata();
}
});

test("selects a conversation while the drawer is closing", async ({
page, request, localTarget,
}) => {
const first = await sendFromComposer(page, "Original conversation history");
await expect(page.getByRole("button", { name: "Export conversation", exact: true })).toBeEnabled();
const otherId = await createConversation(
request, first.attack.attack_result_id, "Selected conversation history",
);
const toggle = page.getByTestId("toggle-panel-btn");
await page.getByRole("button", { name: "Toggle conversations panel", exact: true }).click();
await expect(page.getByRole(viewport.name === "narrow" ? "dialog" : "complementary", {
name: "Attack Conversations", exact: true,
})).toBeVisible();
await expect(page.getByRole("button", { name: `Select conversation ${otherId}`, exact: true })).toBeVisible();
await page.getByRole("button", { name: "Close panel", exact: true }).click();
await expect(toggle).toHaveAttribute("aria-expanded", "false");

await selectConversation(page, otherId);

const transcript = page.getByTestId("message-list");
await expect(transcript.getByText("Selected conversation history", { exact: true })).toBeVisible();
await expect(transcript.getByText("Original conversation history", { exact: true })).toHaveCount(0);
await expect(toggle).toHaveAttribute("aria-expanded", viewport.name === "narrow" ? "false" : "true");
if (viewport.name === "narrow") {
await expect(page.getByRole("dialog", { name: "Attack Conversations", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Toggle conversations panel", exact: true })).toBeFocused();
}
expect(localTarget.requestBodies).toHaveLength(1);
});
});
}

test("repeats and nests only the selected conversation through the real backend", async ({ page, request, localTarget }, testInfo) => {
let submissions = 0;
page.on("request", (outgoing: Request) => { if (isMessagePost(outgoing)) submissions += 1; });
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5141,6 +5141,40 @@ describe("ChatWindow Integration", () => {
expect(screen.queryByTestId("conversation-panel")).not.toBeInTheDocument();
});

it.each([false, true])("should preserve the operator's panel choice after a late conversation update, narrow %s", async (isNarrowScreen: boolean) => {
const user = userEvent.setup();
mockMatchMedia(isNarrowScreen);
const props = {
...defaultProps,
attackResultId: "ar-late-conversations",
conversationId: "conv-main",
activeConversationId: "conv-main",
relatedConversationCount: 0,
};
const rendered = render(
<TestWrapper><ChatWindow {...props} /></TestWrapper>
);
const toggleButton = screen.getByRole("button", { name: "Toggle conversations panel" });
expect(toggleButton).toHaveAttribute("aria-expanded", "false");

await user.click(toggleButton);
await user.click(await screen.findByRole("button", { name: "Close panel" }));
await waitFor(() => {
expect(screen.queryByRole(isNarrowScreen ? "dialog" : "complementary", {
name: "Attack Conversations",
})).not.toBeInTheDocument();
});

rendered.rerender(
<TestWrapper><ChatWindow {...props} relatedConversationCount={1} /></TestWrapper>
);

expect(toggleButton).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole(isNarrowScreen ? "dialog" : "complementary", {
name: "Attack Conversations",
})).not.toBeInTheDocument();
});

it("should keep the mobile drawer closed until requested and restore focus after Escape", async () => {
const user = userEvent.setup();
mockMatchMedia(true);
Expand Down
18 changes: 6 additions & 12 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -452,18 +452,12 @@ export default function ChatWindow({
[activePieceConversions, converters.pipelines, converters.editRevision],
)

// Auto-open conversation sidebar when loading a historical attack with multiple
// conversations. Uses the "adjust state during render" pattern to avoid
// react-hooks/set-state-in-effect.
const [autoOpenedForAttack, setAutoOpenedForAttack] = useState<string | null>(null)
if (
attackResultId
&& attackResultId !== autoOpenedForAttack
&& relatedConversationCount
&& relatedConversationCount > 0
) {
setAutoOpenedForAttack(attackResultId)
if (!isNarrowScreen) {
// Apply the historical attack's drawer default once. Late send refreshes
// must not override the operator's open/closed choice.
const [panelInitializedForAttack, setPanelInitializedForAttack] = useState<string | null>(null)
if (attackResultId && attackResultId !== panelInitializedForAttack) {
setPanelInitializedForAttack(attackResultId)
if (!isNarrowScreen && relatedConversationCount && relatedConversationCount > 0) {
setIsPanelOpen(true)
}
}
Expand Down
Loading