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
20 changes: 18 additions & 2 deletions frontend/src/components/Chat/ChatInputArea.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -223,10 +223,26 @@ describe("ChatInputArea", () => {
</TestWrapper>
);

const attachButton = screen.getByTitle("Attach files");
const attachButton = screen.getByRole("button", { name: /attach files/i });
expect(attachButton).toBeInTheDocument();
});

it("should expose all icon-only buttons via aria-label, not only title", () => {
// Regression guard against using only `title=` for icon buttons.
// `title` is not a reliable accessible name for screen readers; aria-label
// is. All icon-only buttons in the input area must be reachable by role
// + name (i.e., they have an aria-label that matches the visible tooltip).
render(
<TestWrapper>
<ChatInputArea {...defaultProps} />
</TestWrapper>
);

expect(screen.getByRole("button", { name: /attach files/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /toggle converter panel/i })).toBeInTheDocument();
expect(screen.getByRole("button", { name: /send message/i })).toBeInTheDocument();
});

it("should handle file attachment selection", async () => {
const user = userEvent.setup();

Expand Down Expand Up @@ -478,7 +494,7 @@ describe("ChatInputArea", () => {
});

// Send button should be enabled since there's an attachment
expect(screen.getByTitle("Send message")).toBeEnabled();
expect(screen.getByRole("button", { name: /send message/i })).toBeEnabled();
});

it("should show single-turn banner when singleTurnLimitReached is true", () => {
Expand Down
58 changes: 32 additions & 26 deletions frontend/src/components/Chat/ChatInputArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -475,23 +475,27 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
/>
<div className={styles.inputColumns}>
<div className={styles.columnLeft}>
<Button
className={styles.iconButton}
appearance="subtle"
icon={<AttachRegular />}
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
title="Attach files"
/>
<Button
className={styles.iconButton}
appearance={isConverterPanelOpen ? 'primary' : 'subtle'}
icon={<ArrowShuffleRegular />}
onClick={onToggleConverterPanel}
disabled={disabled}
data-testid="toggle-converter-panel-btn"
title="Toggle converter panel"
/>
<Tooltip content="Attach files" relationship="label">
<Button
className={styles.iconButton}
appearance="subtle"
icon={<AttachRegular />}
onClick={() => fileInputRef.current?.click()}
disabled={disabled}
aria-label="Attach files"
/>
</Tooltip>
<Tooltip content="Toggle converter panel" relationship="label">
<Button
className={styles.iconButton}
appearance={isConverterPanelOpen ? 'primary' : 'subtle'}
icon={<ArrowShuffleRegular />}
onClick={onToggleConverterPanel}
disabled={disabled}
data-testid="toggle-converter-panel-btn"
aria-label="Toggle converter panel"
/>
</Tooltip>
</div>
<div className={styles.columnCenter}>
<AttachmentList
Expand Down Expand Up @@ -547,15 +551,17 @@ const ChatInputArea = forwardRef<ChatInputAreaHandle, ChatInputAreaProps>(functi
</span>
</Tooltip>
)}
<Button
className={styles.sendButton}
appearance="primary"
icon={<SendRegular />}
onClick={handleSend}
disabled={disabled || (!input && attachments.length === 0) || hasUnsupportedModalities}
title="Send message"
data-testid="send-message-btn"
/>
<Tooltip content="Send message" relationship="label">
<Button
className={styles.sendButton}
appearance="primary"
icon={<SendRegular />}
onClick={handleSend}
disabled={disabled || (!input && attachments.length === 0) || hasUnsupportedModalities}
aria-label="Send message"
data-testid="send-message-btn"
/>
</Tooltip>
{convertedValue && (
<Tooltip content="Clear conversion" relationship="label">
<Button
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2130,6 +2130,26 @@ describe("ChatWindow Integration", () => {
});
});

it("should expose the conversations panel toggle via aria-label", () => {
// Regression guard: the toggle button is icon-only and previously relied
// on aria-label without a visible tooltip; assert both are wired up so
// the button is reachable by accessible name (catches regression to
// missing aria-label).
render(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-aria-toggle"
conversationId="conv-aria-toggle"
activeConversationId="conv-aria-toggle"
/>
</TestWrapper>
);

const toggleBtn = screen.getByRole("button", { name: /toggle conversations panel/i });
expect(toggleBtn).toBe(screen.getByTestId("toggle-panel-btn"));
});

it("should toggle converter panel when convert button is clicked", async () => {
render(
<TestWrapper>
Expand Down
1 change: 1 addition & 0 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,7 @@ export default function ChatWindow({
onClick={() => setIsPanelOpen(!isPanelOpen)}
disabled={!attackResultId}
data-testid="toggle-panel-btn"
aria-label="Toggle conversations panel"
/>
</Tooltip>
<Button
Expand Down
6 changes: 4 additions & 2 deletions frontend/src/components/Chat/MessageList.styles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,15 @@ export const useMessageListStyles = makeStyles({
flexDirection: 'row-reverse',
},
messageContent: {
backgroundColor: tokens.colorNeutralBackground3,
backgroundColor: tokens.colorNeutralBackground1,
border: `1px solid ${tokens.colorNeutralStroke2}`,
padding: tokens.spacingVerticalM,
borderRadius: tokens.borderRadiusMedium,
flex: 1,
},
userMessageContent: {
backgroundColor: tokens.colorBrandBackground,
backgroundColor: tokens.colorBrandBackground2,
border: `1px solid ${tokens.colorBrandStroke2}`,
},
messageText: {
whiteSpace: 'pre-wrap',
Expand Down
78 changes: 78 additions & 0 deletions frontend/src/components/Chat/MessageList.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,50 @@ describe("MessageList", () => {
expect(screen.getByText("Assistant message test")).toBeInTheDocument();
});

describe("bubble class composition", () => {
// Regression guard for an earlier bug where the user-bubble background
// override silently lost to the assistant-bubble base style. The cause was
// string-concatenated class names — Griffel's atomic CSS doesn't dedupe
// conflicting properties unless mergeClasses is used. We assert here that
// the two bubble containers receive distinguishable className strings, so
// the override always has a chance to win.
it("renders user and assistant bubbles with distinct class signatures", () => {
render(
<TestWrapper>
<MessageList
messages={[
{
role: "user",
content: "u",
timestamp: new Date().toISOString(),
},
{
role: "assistant",
content: "a",
timestamp: new Date().toISOString(),
},
]}
/>
</TestWrapper>
);
const userBubble = screen.getByTestId("message-bubble-0");
const assistantBubble = screen.getByTestId("message-bubble-1");
// The two bubble class strings must differ — otherwise the user
// override silently lost (which was the original bug).
expect(userBubble.className).not.toBe(assistantBubble.className);
// Both bubbles must carry at least one style hook (catches a future
// refactor that drops the className entirely).
expect(userBubble.className.trim()).not.toBe('');
expect(assistantBubble.className.trim()).not.toBe('');
// The user bubble must carry at least one style hook the assistant
// bubble doesn't have — that's the override actually being applied.
const userClasses = userBubble.className.split(/\s+/).filter(Boolean);
const assistantClasses = new Set(assistantBubble.className.split(/\s+/).filter(Boolean));
const userOnly = userClasses.filter(c => !assistantClasses.has(c));
expect(userOnly.length).toBeGreaterThan(0);
});
});

it("should handle messages with image attachments", () => {
const messagesWithAttachments: Message[] = [
{
Expand Down Expand Up @@ -800,6 +844,40 @@ describe("MessageList", () => {
expect(btn).toBeInTheDocument();
expect(btn).toBeDisabled();
});

it("should give the four disabled action buttons distinct accessible names", () => {
// Regression guard: previously several disabled-state tooltips collapsed
// to identical strings (e.g. both branch buttons read
// "Cannot branch — target is single-turn"), so a screen reader could
// not tell them apart. Each disabled action's accessible name must be
// unique.
render(
<TestWrapper>
<MessageList
messages={assistantMessage}
onCopyToInput={jest.fn()}
onCopyToNewConversation={jest.fn()}
onBranchConversation={jest.fn()}
onBranchAttack={jest.fn()}
isSingleTurn={true}
/>
</TestWrapper>
);

const btns = [
screen.getByTestId("copy-to-input-btn-0"),
screen.getByTestId("copy-to-new-conv-btn-0"),
screen.getByTestId("branch-conv-btn-0"),
screen.getByTestId("branch-attack-btn-0"),
];
const names = btns.map(b => b.getAttribute("aria-label") ?? "");
// None empty
for (const name of names) {
expect(name).not.toBe("");
}
// All distinct
expect(new Set(names).size).toBe(names.length);
});
});

describe("original vs converted display", () => {
Expand Down
34 changes: 19 additions & 15 deletions frontend/src/components/Chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
Button,
Tooltip,
Spinner,
mergeClasses,
} from '@fluentui/react-components'
import { ArrowDownloadRegular, ArrowReplyRegular, ArrowForwardRegular, ChatAddRegular, BranchForkRegular } from '@fluentui/react-icons'
import { Message, MessageAttachment } from '../../types'
Expand Down Expand Up @@ -138,13 +139,16 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
return (
<div
key={index}
className={`${styles.message} ${isUser ? styles.userMessage : ''}`}
className={mergeClasses(styles.message, isUser && styles.userMessage)}
>
<Avatar
name={avatarName}
color={isUser ? 'colorful' : isSimulated ? 'steel' : 'brand'}
/>
<div className={`${styles.messageContent} ${isUser ? styles.userMessageContent : ''}`}>
<div
className={mergeClasses(styles.messageContent, isUser && styles.userMessageContent)}
data-testid={`message-bubble-${index}`}
>
{/* Error rendering */}
{message.error && (
<div className={styles.errorContainer}>
Expand Down Expand Up @@ -248,13 +252,13 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
{onCopyToInput && (() => {
const disabled = Boolean(noTargetSelected || isSingleTurn || isOperatorLocked || isCrossTarget)
const tip = noTargetSelected
? 'Cannot copy — no target selected'
? 'Cannot copy to this conversation — no target selected'
: isSingleTurn
? 'Cannot copy — target is single-turn'
? 'Cannot copy to this conversation — target is single-turn'
: isOperatorLocked
? 'Cannot copy — you are not the operator of this attack'
? 'Cannot copy to this conversation — you are not the operator of this attack'
: isCrossTarget
? 'Cannot copy conversation used a different target'
? 'Cannot copy to this conversation — it used a different target'
: 'Copy to input box in this conversation'
return (
<Tooltip content={tip} relationship="label">
Expand All @@ -275,11 +279,11 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
{onCopyToNewConversation && (() => {
const disabled = Boolean(noTargetSelected || isOperatorLocked || isCrossTarget)
const tip = noTargetSelected
? 'Cannot copy — no target selected'
? 'Cannot copy to a new conversation — no target selected'
: isOperatorLocked
? 'Cannot add to this attack — you are not the operator'
? 'Cannot copy to a new conversation — you are not the operator of this attack'
: isCrossTarget
? 'Cannot add to this attack — conversation used a different target'
? 'Cannot copy to a new conversation — this attack used a different target'
: 'Copy to input box in a new conversation'
return (
<Tooltip content={tip} relationship="label">
Expand All @@ -300,13 +304,13 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
{onBranchConversation && (() => {
const disabled = Boolean(noTargetSelected || isSingleTurn || isOperatorLocked || isCrossTarget)
const tip = noTargetSelected
? 'Cannot branch — no target selected'
? 'Cannot branch into new conversation — no target selected'
: isSingleTurn
? 'Cannot branch — target is single-turn'
? 'Cannot branch into new conversation — target is single-turn'
: isOperatorLocked
? 'Cannot add to this attack — you are not the operator'
? 'Cannot branch into new conversation — you are not the operator of this attack'
: isCrossTarget
? 'Cannot add to this attackconversation used a different target'
? 'Cannot branch into new conversationthis attack used a different target'
: 'Branch into new conversation'
return (
<Tooltip content={tip} relationship="label">
Expand Down Expand Up @@ -342,9 +346,9 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
}
// Show disabled button with reason
const tip = noTargetSelected
? 'Cannot branch — no target selected'
? 'Cannot branch into new attack — no target selected'
: singleTurnBlock
? 'Cannot branch — target is single-turn'
? 'Cannot branch into new attack — target is single-turn'
: undefined
if (!tip) return null
return (
Expand Down
Loading