Skip to content

Commit 0785e36

Browse files
Render the system prompt as a sticky banner above the transcript
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent bb8d578 commit 0785e36

7 files changed

Lines changed: 209 additions & 0 deletions

File tree

frontend/src/components/Chat/ChatWindow.test.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -532,6 +532,29 @@ describe("ChatWindow Integration", () => {
532532
).not.toBeInTheDocument();
533533
});
534534

535+
it("renders a system prompt banner when the loaded conversation has a system message", async () => {
536+
mockedAttacksApi.getMessages.mockResolvedValue({ messages: [] });
537+
mockedMapper.backendMessagesToFrontend.mockReturnValue([
538+
{ role: "system", content: "You are a pirate.", timestamp: "2026-01-01T00:00:00Z" },
539+
{ role: "user", content: "Ahoy", timestamp: "2026-01-01T00:00:01Z" },
540+
]);
541+
542+
render(
543+
<TestWrapper>
544+
<ChatWindow
545+
{...defaultProps}
546+
activeTarget={supportedTarget}
547+
attackResultId="ar-existing"
548+
conversationId="conv-existing"
549+
activeConversationId="conv-existing"
550+
/>
551+
</TestWrapper>
552+
);
553+
554+
expect(await screen.findByTestId("system-prompt-banner")).toBeInTheDocument();
555+
expect(screen.getByText("You are a pirate.")).toBeInTheDocument();
556+
});
557+
535558
it("forwards the typed system prompt when the target supports it", async () => {
536559
const user = userEvent.setup();
537560
primeSendMocks();

frontend/src/components/Chat/ChatWindow.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
} from '@fluentui/react-components'
77
import { AddRegular, PanelRightRegular } from '@fluentui/react-icons'
88
import MessageList from './MessageList'
9+
import SystemPromptBanner from './SystemPromptBanner'
910
import ChatInputArea from './ChatInputArea'
1011
import ConversationPanel from './ConversationPanel'
1112
import ConverterPanel from './ConverterPanel'
@@ -579,6 +580,8 @@ export default function ChatWindow({
579580
}
580581
}, [attackResultId, activeTarget, activeConversationId, messages, labels, onConversationCreated])
581582

583+
const systemMessage = messages.find(message => message.role === 'system')
584+
582585
return (
583586
<div className={styles.root}>
584587
{isConverterPanelOpen && (
@@ -632,6 +635,7 @@ export default function ChatWindow({
632635
</Tooltip>
633636
</div>
634637
</div>
638+
{systemMessage && <SystemPromptBanner content={systemMessage.content} />}
635639
<MessageList
636640
messages={messages}
637641
onCopyToInput={handleCopyToInput}

frontend/src/components/Chat/MessageList.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,26 @@ describe("MessageList", () => {
5353
expect(screen.getByText("Can you help me?")).toBeInTheDocument();
5454
});
5555

56+
it("should not render system messages as transcript bubbles", () => {
57+
const withSystem: Message[] = [
58+
{
59+
role: "system",
60+
content: "You are a pirate.",
61+
timestamp: new Date().toISOString(),
62+
},
63+
...mockMessages,
64+
];
65+
66+
render(
67+
<TestWrapper>
68+
<MessageList messages={withSystem} />
69+
</TestWrapper>
70+
);
71+
72+
expect(screen.queryByText("You are a pirate.")).not.toBeInTheDocument();
73+
expect(screen.getByText("Hello, how are you?")).toBeInTheDocument();
74+
});
75+
5676
it("should render user messages", () => {
5777
const userMessages: Message[] = [
5878
{

frontend/src/components/Chat/MessageList.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ export default function MessageList({ messages, onCopyToInput, onCopyToNewConver
154154
return (
155155
<div className={styles.root}>
156156
{messages.map((message, index) => {
157+
if (message.role === 'system') return null
157158
const isUser = message.role === 'user'
158159
const isSimulated = message.role === 'simulated_assistant'
159160
const timestamp = new Date(message.timestamp).toLocaleTimeString()
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { makeStyles, tokens } from '@fluentui/react-components'
2+
3+
export const useSystemPromptBannerStyles = makeStyles({
4+
root: {
5+
flexShrink: 0,
6+
display: 'flex',
7+
flexDirection: 'column',
8+
gap: tokens.spacingVerticalXXS,
9+
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalL}`,
10+
backgroundColor: tokens.colorNeutralBackground3,
11+
borderBottom: `1px solid ${tokens.colorNeutralStroke1}`,
12+
},
13+
header: {
14+
alignSelf: 'flex-start',
15+
color: tokens.colorNeutralForeground2,
16+
},
17+
label: {
18+
alignSelf: 'flex-start',
19+
color: tokens.colorNeutralForeground2,
20+
fontWeight: tokens.fontWeightSemibold,
21+
},
22+
content: {
23+
color: tokens.colorNeutralForeground3,
24+
fontSize: tokens.fontSizeBase200,
25+
paddingLeft: tokens.spacingHorizontalL,
26+
},
27+
contentCollapsed: {
28+
whiteSpace: 'nowrap',
29+
overflow: 'hidden',
30+
textOverflow: 'ellipsis',
31+
},
32+
contentExpanded: {
33+
whiteSpace: 'pre-wrap',
34+
wordBreak: 'break-word',
35+
maxHeight: '30vh',
36+
overflowY: 'auto',
37+
},
38+
})
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import { render, screen } from '@testing-library/react'
2+
import userEvent from '@testing-library/user-event'
3+
import { FluentProvider, webLightTheme } from '@fluentui/react-components'
4+
import SystemPromptBanner from './SystemPromptBanner'
5+
6+
const TestWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
7+
<FluentProvider theme={webLightTheme}>{children}</FluentProvider>
8+
)
9+
10+
// jsdom has no layout engine, so scrollWidth/clientWidth are 0 by default (no overflow).
11+
// Force overflow by overriding the prototype getters for the duration of a test.
12+
function mockOverflow(scrollWidth: number, clientWidth: number) {
13+
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', { configurable: true, get: () => scrollWidth })
14+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', { configurable: true, get: () => clientWidth })
15+
}
16+
17+
describe('SystemPromptBanner', () => {
18+
afterEach(() => {
19+
delete (HTMLElement.prototype as { scrollWidth?: number }).scrollWidth
20+
delete (HTMLElement.prototype as { clientWidth?: number }).clientWidth
21+
})
22+
23+
it('renders the label and the system prompt content', () => {
24+
render(
25+
<TestWrapper>
26+
<SystemPromptBanner content="You are a pirate." />
27+
</TestWrapper>
28+
)
29+
30+
expect(screen.getByText('System Prompt')).toBeInTheDocument()
31+
expect(screen.getByText('You are a pirate.')).toBeInTheDocument()
32+
})
33+
34+
it('does not render an expand toggle when the content fits on one line', () => {
35+
render(
36+
<TestWrapper>
37+
<SystemPromptBanner content="Be terse." />
38+
</TestWrapper>
39+
)
40+
41+
expect(screen.queryByRole('button', { name: /system prompt/i })).not.toBeInTheDocument()
42+
})
43+
44+
it('renders a collapsed expand toggle when the content overflows', () => {
45+
mockOverflow(1000, 200)
46+
render(
47+
<TestWrapper>
48+
<SystemPromptBanner content="A very long system prompt that does not fit on one line." />
49+
</TestWrapper>
50+
)
51+
52+
expect(screen.getByRole('button', { name: /system prompt/i })).toHaveAttribute('aria-expanded', 'false')
53+
})
54+
55+
it('expands when the overflowing header is clicked', async () => {
56+
const user = userEvent.setup()
57+
mockOverflow(1000, 200)
58+
render(
59+
<TestWrapper>
60+
<SystemPromptBanner content="A very long system prompt that does not fit on one line." />
61+
</TestWrapper>
62+
)
63+
64+
const toggle = screen.getByRole('button', { name: /system prompt/i })
65+
await user.click(toggle)
66+
67+
expect(toggle).toHaveAttribute('aria-expanded', 'true')
68+
})
69+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useLayoutEffect, useRef, useState } from 'react'
2+
import { Button, Caption1, Text, mergeClasses } from '@fluentui/react-components'
3+
import { ChevronDownRegular, ChevronRightRegular } from '@fluentui/react-icons'
4+
import { useSystemPromptBannerStyles } from './SystemPromptBanner.styles'
5+
6+
interface SystemPromptBannerProps {
7+
content: string
8+
}
9+
10+
export default function SystemPromptBanner({ content }: SystemPromptBannerProps) {
11+
const styles = useSystemPromptBannerStyles()
12+
const [expanded, setExpanded] = useState(false)
13+
const [overflowing, setOverflowing] = useState(false)
14+
const contentRef = useRef<HTMLElement>(null)
15+
16+
useLayoutEffect(() => {
17+
const el = contentRef.current
18+
if (!el) return
19+
const measure = () => setOverflowing(el.scrollWidth > el.clientWidth)
20+
measure()
21+
const observer = new ResizeObserver(measure)
22+
observer.observe(el)
23+
return () => observer.disconnect()
24+
}, [content])
25+
26+
const expandable = overflowing || expanded
27+
28+
return (
29+
<div className={styles.root} data-testid="system-prompt-banner">
30+
{expandable ? (
31+
<Button
32+
appearance="transparent"
33+
size="small"
34+
icon={expanded ? <ChevronDownRegular /> : <ChevronRightRegular />}
35+
onClick={() => setExpanded(prev => !prev)}
36+
className={styles.header}
37+
data-testid="toggle-system-prompt-banner-btn"
38+
aria-expanded={expanded}
39+
>
40+
System Prompt
41+
</Button>
42+
) : (
43+
<Caption1 className={styles.label}>System Prompt</Caption1>
44+
)}
45+
<Text
46+
ref={contentRef}
47+
className={mergeClasses(styles.content, expanded ? styles.contentExpanded : styles.contentCollapsed)}
48+
data-testid="system-prompt-banner-content"
49+
>
50+
{content}
51+
</Text>
52+
</div>
53+
)
54+
}

0 commit comments

Comments
 (0)