From 9360fa40ee749daeb8fa86b033f5d624e64c979c Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:54:40 -0700 Subject: [PATCH] FIX: Repair responsive target configuration Keep configuration actions and Round Robin target controls contained at narrow widths while preserving access to long registry names. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 07dadfa9-6127-4169-a9ae-8de30d98b59d --- frontend/e2e/config.spec.ts | 167 +++++++++++++++++- .../Config/CreateTargetDialog.styles.ts | 99 ++++++++++- .../Config/CreateTargetDialog.test.tsx | 54 ++++++ .../components/Config/CreateTargetDialog.tsx | 95 +++++----- .../components/Config/TargetConfig.styles.ts | 27 ++- .../src/components/Config/TargetConfig.tsx | 4 +- .../components/Config/TargetTable.styles.ts | 15 ++ .../src/components/Config/TargetTable.tsx | 6 +- .../components/Layout/MainLayout.styles.ts | 3 + 9 files changed, 421 insertions(+), 49 deletions(-) diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index 5c2e4d8828..b7b0eebdd9 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, type Page } from "@playwright/test"; +import { test, expect, type Locator, type Page } from "@playwright/test"; import { makeTarget, type FlatTarget } from "./_targets"; // --------------------------------------------------------------------------- @@ -32,6 +32,76 @@ const SAMPLE_TARGETS = [ }, ]; +const LONG_REGISTRY_NAME_A = + "openai-production-eastus2-red-team-evaluation-primary-deployment"; +const LONG_REGISTRY_NAME_B = + "openai-production-eastus2-red-team-evaluation-secondary-deployment"; + +const LONG_NAME_TARGETS: FlatTarget[] = [ + { + target_registry_name: LONG_REGISTRY_NAME_A, + target_type: "OpenAIChatTarget", + endpoint: "https://primary.openai.azure.com", + model_name: "gpt-4o-responsive", + }, + { + target_registry_name: LONG_REGISTRY_NAME_B, + target_type: "OpenAIChatTarget", + endpoint: "https://secondary.openai.azure.com", + model_name: "gpt-4o-responsive", + }, +]; + +const RESPONSIVE_VIEWPORTS = [ + { name: "mobile", width: 390, height: 844 }, + { name: "desktop", width: 1280, height: 800 }, +] as const; + +async function routeResponsiveTargetData( + page: Page, + targets: FlatTarget[] +): Promise { + await page.route(/\/api\/targets\/catalog(?:\?.*)?$/, async (route) => { + await route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + items: [ + { + target_type: "OpenAIChatTarget", + parameters: [], + supported_auth_modes: ["api_key", "identity"], + }, + { + target_type: "RoundRobinTarget", + parameters: [], + supported_auth_modes: ["api_key"], + }, + ], + }), + }); + }); + await page.route(/\/api\/targets(?:\?.*)?$/, async (route) => { + await route.fulfill(mockTargetsList(targets)); + }); +} + +async function expectWithin( + child: Locator, + container: Locator +): Promise { + const childBox = await child.boundingBox(); + const containerBox = await container.boundingBox(); + if (!childBox || !containerBox) { + throw new Error("Expected visible child and container bounds"); + } + + expect(childBox.x).toBeGreaterThanOrEqual(containerBox.x); + expect(childBox.x + childBox.width).toBeLessThanOrEqual( + containerBox.x + containerBox.width + ); +} + /** Navigate to the config view. */ async function goToConfig(page: Page) { await page.goto("/"); @@ -219,6 +289,101 @@ test.describe("Create Target Dialog", () => { }); }); +test.describe("Responsive Target Configuration", () => { + for (const viewport of RESPONSIVE_VIEWPORTS) { + test(`should contain configuration actions at ${viewport.name} width`, async ({ + page, + }) => { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await routeResponsiveTargetData(page, LONG_NAME_TARGETS); + await goToConfig(page); + await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible(); + + const config = page.getByTestId("target-config"); + const newTargetButton = page.getByRole("button", { name: /new target/i }); + await expect(newTargetButton).toBeVisible(); + await expectWithin(newTargetButton, config); + + const configWidth = await config.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(configWidth.scrollWidth).toBeLessThanOrEqual( + configWidth.clientWidth + ); + + await newTargetButton.click(); + await expect(page.getByRole("dialog")).toBeVisible(); + }); + + test(`should contain long Round Robin selections at ${viewport.name} width`, async ({ + page, + }) => { + await page.setViewportSize({ + width: viewport.width, + height: viewport.height, + }); + await routeResponsiveTargetData(page, LONG_NAME_TARGETS); + await goToConfig(page); + await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible(); + + await page.getByRole("button", { name: /new target/i }).click(); + const dialog = page.getByRole("dialog"); + const dialogBox = await dialog.boundingBox(); + if (!dialogBox) { + throw new Error("Expected the Create Target dialog to be visible"); + } + if (viewport.name === "desktop") { + expect(dialogBox.width).toBeLessThanOrEqual(640); + } + await dialog.locator("select").first().selectOption("RoundRobinTarget"); + + const addTargetSelect = dialog.locator("select").nth(1); + await addTargetSelect.selectOption(LONG_REGISTRY_NAME_A); + await addTargetSelect.selectOption(LONG_REGISTRY_NAME_B); + + const firstTargetName = dialog.getByLabel( + `Selected target: ${LONG_REGISTRY_NAME_A} (gpt-4o-responsive)` + ); + await expect(firstTargetName).toBeVisible(); + await firstTargetName.focus(); + await expect(page.getByRole("tooltip")).toContainText( + LONG_REGISTRY_NAME_A + ); + + const form = page.getByTestId("create-target-form"); + const formWidths = await form.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(formWidths.scrollWidth).toBeLessThanOrEqual( + formWidths.clientWidth + ); + const dialogWidths = await dialog.evaluate((element) => ({ + clientWidth: element.clientWidth, + scrollWidth: element.scrollWidth, + })); + expect(dialogWidths.scrollWidth).toBeLessThanOrEqual( + dialogWidths.clientWidth + ); + + await expectWithin( + dialog.getByLabel(`Weight for ${LONG_REGISTRY_NAME_A}`), + dialog + ); + await expectWithin( + dialog.getByRole("button", { + name: `Remove ${LONG_REGISTRY_NAME_B}`, + }), + dialog + ); + }); + } +}); + test.describe("Target Config ↔ Chat Navigation", () => { test("should display active target info in chat after setting it", async ({ page }) => { await page.route(/\/api\/targets/, async (route) => { diff --git a/frontend/src/components/Config/CreateTargetDialog.styles.ts b/frontend/src/components/Config/CreateTargetDialog.styles.ts index f63ca604d7..4c6a50e304 100644 --- a/frontend/src/components/Config/CreateTargetDialog.styles.ts +++ b/frontend/src/components/Config/CreateTargetDialog.styles.ts @@ -1,11 +1,39 @@ import { makeStyles, tokens } from '@fluentui/react-components' export const useCreateTargetDialogStyles = makeStyles({ + dialogSurface: { + width: '100%', + minWidth: 0, + maxWidth: '37.5rem', + '@media (max-width: 600px)': { + maxWidth: `calc(100vw - ${tokens.spacingHorizontalXXL} - ${tokens.spacingHorizontalXXL})`, + }, + }, + dialogContent: { + minWidth: 0, + }, form: { display: 'flex', flexDirection: 'column', + width: '100%', + minWidth: 0, + maxWidth: '100%', gap: tokens.spacingVerticalL, }, + formField: { + minWidth: 0, + maxWidth: '100%', + }, + fullWidthSelect: { + width: '100%', + minWidth: 0, + maxWidth: '100%', + '& select': { + width: '100%', + minWidth: 0, + maxWidth: '100%', + }, + }, warningMessage: { width: '100%', }, @@ -14,19 +42,84 @@ export const useCreateTargetDialogStyles = makeStyles({ overflowWrap: 'anywhere', wordBreak: 'break-word', }, - /** Container for the list of selected inner targets in the RoundRobin form. */ + selectedTargetsSection: { + minWidth: 0, + maxWidth: '100%', + }, + selectedTargetsLabel: { + display: 'block', + marginBottom: tokens.spacingVerticalXS, + }, selectedTargetsList: { display: 'flex', flexDirection: 'column', + minWidth: 0, + maxWidth: '100%', gap: tokens.spacingVerticalXS, }, - /** A single row in the selected targets list: target name + weight + remove button. */ selectedTargetRow: { - display: 'flex', + display: 'grid', + gridTemplateColumns: 'minmax(0, 1fr) auto', alignItems: 'center', + minWidth: 0, + maxWidth: '100%', gap: tokens.spacingHorizontalS, padding: `${tokens.spacingVerticalXS} ${tokens.spacingHorizontalS}`, backgroundColor: tokens.colorNeutralBackground2, borderRadius: tokens.borderRadiusSmall, + '@media (max-width: 600px)': { + gridTemplateColumns: 'minmax(0, 1fr)', + alignItems: 'stretch', + }, + }, + selectedTargetName: { + display: 'block', + minWidth: 0, + maxWidth: '100%', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', + cursor: 'help', + '@media (max-width: 600px)': { + overflow: 'visible', + overflowWrap: 'anywhere', + textOverflow: 'clip', + whiteSpace: 'normal', + cursor: 'text', + }, + }, + targetNameTooltip: { + overflowWrap: 'anywhere', + wordBreak: 'break-word', + }, + selectedTargetControlGroup: { + display: 'flex', + flexDirection: 'column', + minWidth: 0, + }, + selectedTargetControls: { + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + flexWrap: 'wrap', + minWidth: 0, + gap: tokens.spacingHorizontalXS, + '@media (max-width: 600px)': { + justifyContent: 'flex-start', + }, + }, + weightInput: { + width: '5rem', + minWidth: '5rem', + }, + weightError: { + alignSelf: 'flex-end', + marginTop: tokens.spacingVerticalXXS, + color: tokens.colorPaletteRedForeground1, + textAlign: 'right', + '@media (max-width: 600px)': { + alignSelf: 'flex-start', + textAlign: 'left', + }, }, }) diff --git a/frontend/src/components/Config/CreateTargetDialog.test.tsx b/frontend/src/components/Config/CreateTargetDialog.test.tsx index 62c2a7e8fe..288c9478bb 100644 --- a/frontend/src/components/Config/CreateTargetDialog.test.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.test.tsx @@ -946,6 +946,60 @@ describe("CreateTargetDialog", () => { expect(createButton).toBeDisabled(); }); + it("should keep full long registry names accessible after selecting RoundRobin targets", async () => { + const user = userEvent.setup(); + const firstRegistryName = + "openai-production-eastus2-red-team-evaluation-primary-deployment"; + const secondRegistryName = + "openai-production-eastus2-red-team-evaluation-secondary-deployment"; + + render( + + + + ); + + await selectTargetType(user, "RoundRobinTarget"); + const select = screen.getByText("Select a target to add...").closest("select"); + expect(select).not.toBeNull(); + if (!select) { + throw new Error("Round Robin target selector was not rendered"); + } + + await user.selectOptions(select, firstRegistryName); + await user.selectOptions(select, secondRegistryName); + + const selectedName = screen.getByLabelText( + `Selected target: ${firstRegistryName} (gpt-4o)` + ); + expect(selectedName).toHaveAttribute("tabindex", "0"); + await user.click(selectedName); + expect(selectedName).toHaveFocus(); + + expect( + screen.getByRole("button", { name: `Remove ${firstRegistryName}` }) + ).toBeInTheDocument(); + expect( + screen.getByLabelText(`Weight for ${secondRegistryName}`) + ).toBeInTheDocument(); + }); + it("filters duplicate-by-identifier-hash targets out of the picker once one is selected", async () => { const user = userEvent.setup(); diff --git a/frontend/src/components/Config/CreateTargetDialog.tsx b/frontend/src/components/Config/CreateTargetDialog.tsx index 9fcfebcd2d..a3d30f8565 100644 --- a/frontend/src/components/Config/CreateTargetDialog.tsx +++ b/frontend/src/components/Config/CreateTargetDialog.tsx @@ -19,6 +19,7 @@ import { Field, MessageBar, MessageBarBody, + Tooltip, } from '@fluentui/react-components' import { DeleteRegular } from '@fluentui/react-icons' import { targetsApi } from '@/services/api' @@ -431,11 +432,15 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT return ( { if (!data.open) handleClose() }}> - + Create New Target - -
{ e.preventDefault(); handleSubmit() }}> + + { e.preventDefault(); handleSubmit() }} + > {error && ( {error} @@ -443,12 +448,14 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT )} { if (data.value) addInnerTarget(data.value) @@ -495,8 +503,8 @@ export default function CreateTargetDialog({ open, onClose, onCreated, existingT {selectedInnerTargets.length > 0 && ( -
-