Skip to content
Draft
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
36 changes: 36 additions & 0 deletions frontend/src/components/Chat/ChatWindow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ jest.mock("../../services/api", () => ({
getConversations: jest.fn(),
createConversation: jest.fn(),
changeMainConversation: jest.fn(),
scoreConversation: jest.fn(),
scoreMessagePiece: jest.fn(),
},
convertersApi: {
listConverterCatalog: jest.fn(),
Expand All @@ -40,6 +42,9 @@ jest.mock("../../services/api", () => ({
createConverter: jest.fn(),
previewConversion: jest.fn(),
},
scorersApi: {
listScorers: jest.fn().mockResolvedValue({ items: [] }),
},
labelsApi: {
getLabels: jest.fn().mockImplementation(() => new Promise(() => {})),
},
Expand Down Expand Up @@ -2159,6 +2164,37 @@ describe("ChatWindow Integration", () => {
expect(toggleBtn).toBe(screen.getByTestId("toggle-panel-btn"));
});

it("ribbon Score button is disabled until a conversation is active", () => {
render(
<TestWrapper>
<ChatWindow {...defaultProps} attackResultId={null} activeConversationId={null} />
</TestWrapper>
);
expect(screen.getByTestId("score-conversation-btn")).toBeDisabled();
});

it("ribbon Score button opens the score dialog for the active conversation", async () => {
render(
<TestWrapper>
<ChatWindow
{...defaultProps}
attackResultId="ar-score-ribbon"
conversationId="conv-score-ribbon"
activeConversationId="conv-score-ribbon"
/>
</TestWrapper>
);

const scoreBtn = screen.getByTestId("score-conversation-btn");
expect(scoreBtn).toBeEnabled();
await userEvent.click(scoreBtn);

// ScoreDialog mounts and fetches scorers (mock resolves to empty list).
await waitFor(() => {
expect(screen.getByTestId("score-dialog-empty")).toBeInTheDocument();
});
});

it("should toggle converter panel when convert button is clicked", async () => {
render(
<TestWrapper>
Expand Down
86 changes: 85 additions & 1 deletion frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import {
Text,
Tooltip,
} from '@fluentui/react-components'
import { AddRegular, PanelRightRegular } from '@fluentui/react-icons'
import { AddRegular, PanelRightRegular, DataBarVerticalRegular } from '@fluentui/react-icons'
import MessageList from './MessageList'
import ChatInputArea from './ChatInputArea'
import ConversationPanel from './ConversationPanel'
import ConverterPanel from './ConverterPanel'
import TargetBadge from './TargetBadge'
import ScoreDialog, { type ScoreTarget } from './ScoreDialog'
import type { PieceConversion } from './converterTypes'
import { PIECE_TYPE_TO_DATA_TYPE, basenameFromValue, buildMediaUrl, dataTypeToAttachmentKind, isPathDataType } from './converterTypes'
import LabelsBar from '../Labels/LabelsBar'
Expand Down Expand Up @@ -74,6 +75,15 @@ export default function ChatWindow({
const [attachmentData, setAttachmentData] = useState<Record<string, string>>({})
const [pieceConversions, setPieceConversions] = useState<Record<string, PieceConversion>>({})
const [panelRefreshKey, setPanelRefreshKey] = useState(0)
const [scoreTarget, setScoreTarget] = useState<ScoreTarget | null>(null)
// Last-used scorer per conversation id. Lets the score dialog pre-select the
// scorer the user previously picked for the same conversation. Persists for
// the lifetime of the ChatWindow (not across page reloads); the user can
// still pick a different scorer at any time.
const [scorerByConversation, setScorerByConversation] = useState<Record<string, string>>({})
// Last-typed objective per conversation id. Mirrors scorerByConversation so
// re-opening the dialog pre-fills the objective the user previously typed.
const [objectiveByConversation, setObjectiveByConversation] = useState<Record<string, string>>({})
const inputBoxRef = useRef<ChatInputAreaHandle>(null)

const handleAttachmentsChange = useCallback((types: string[], data: Record<string, string>) => {
Expand Down Expand Up @@ -485,6 +495,28 @@ export default function ChatWindow({
}
}, [attackResultId])

// Open the score dialog for a specific assistant message piece.
const handleScoreMessage = useCallback((messageIndex: number) => {
if (!attackResultId || !activeConversationId) return
const msg = messages[messageIndex]
if (!msg?.pieceId) return
setScoreTarget({
kind: 'piece',
attackResultId,
conversationId: activeConversationId,
pieceId: msg.pieceId,
})
}, [attackResultId, activeConversationId, messages])

// After any score completes, refetch messages so the new score badges appear
// and bump the conversation panel refresh so its scoreboard / count stays current.
const handleScored = useCallback(() => {
if (attackResultId && activeConversationId) {
loadConversation(attackResultId, activeConversationId)
}
setPanelRefreshKey(k => k + 1)
}, [attackResultId, activeConversationId, loadConversation])

const singleTurnLimitReached = activeTarget?.capabilities?.supports_multi_turn === false && messages.some(m => m.role === 'user')

// Operator locking: if the loaded attack's operator differs from the current
Expand Down Expand Up @@ -564,6 +596,32 @@ export default function ChatWindow({
)}
</div>
<div className={styles.ribbonActions}>
<Tooltip
content={
!attackResultId || !activeConversationId
? 'Score conversation — start or load a conversation first'
: 'Score this conversation'
}
relationship="label"
>
<Button
appearance="subtle"
icon={<DataBarVerticalRegular />}
onClick={() => {
if (!attackResultId || !activeConversationId) return
setScoreTarget({
kind: 'conversation',
attackResultId,
conversationId: activeConversationId,
})
}}
disabled={!attackResultId || !activeConversationId}
data-testid="score-conversation-btn"
aria-label="Score conversation"
>
Score conversation
</Button>
</Tooltip>
<Tooltip content="Toggle conversations panel" relationship="label">
<Button
appearance="subtle"
Expand Down Expand Up @@ -595,6 +653,7 @@ export default function ChatWindow({
onCopyToNewConversation={attackResultId ? handleCopyToNewConversation : undefined}
onBranchConversation={attackResultId && activeConversationId ? handleBranchConversation : undefined}
onBranchAttack={activeTarget && activeConversationId ? handleBranchAttack : undefined}
onScoreMessage={attackResultId && activeConversationId ? handleScoreMessage : undefined}
isLoading={isLoadingAttack || isLoadingMessages || awaitingConversationLoad}
isSingleTurn={activeTarget?.capabilities?.supports_multi_turn === false}
isOperatorLocked={isOperatorLocked}
Expand Down Expand Up @@ -663,8 +722,33 @@ export default function ChatWindow({
: undefined
}
refreshKey={panelRefreshKey}
onConversationScored={handleScored}
/>
)}
<ScoreDialog
open={scoreTarget != null}
target={scoreTarget}
onClose={() => setScoreTarget(null)}
onScored={() => { setScoreTarget(null); handleScored() }}
initialScorerName={scoreTarget ? scorerByConversation[scoreTarget.conversationId] : undefined}
onScorerSelected={(name) => {
if (!scoreTarget) return
setScorerByConversation((prev) =>
prev[scoreTarget.conversationId] === name
? prev
: { ...prev, [scoreTarget.conversationId]: name }
)
}}
initialObjective={scoreTarget ? objectiveByConversation[scoreTarget.conversationId] : undefined}
onObjectiveChange={(value) => {
if (!scoreTarget) return
setObjectiveByConversation((prev) =>
prev[scoreTarget.conversationId] === value
? prev
: { ...prev, [scoreTarget.conversationId]: value }
)
}}
/>
</div>
)
}
39 changes: 38 additions & 1 deletion frontend/src/components/Chat/ConversationPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,13 @@ import {
DismissRegular,
StarRegular,
StarFilled,
DataBarVerticalRegular,
} from '@fluentui/react-icons'
import { attacksApi } from '../../services/api'
import { toApiError } from '../../services/errors'
import type { ConversationSummary } from '../../types'
import type { BackendScore, ConversationSummary } from '../../types'
import { useConversationPanelStyles } from './ConversationPanel.styles'
import ScoreDialog, { type ScoreTarget } from './ScoreDialog'

interface ConversationPanelProps {
attackResultId: string | null
Expand All @@ -34,6 +36,8 @@ interface ConversationPanelProps {
lockedReason?: string
/** Increment to trigger a conversation list refresh (e.g. after sending a message) */
refreshKey?: number
/** Called after a conversation is scored so the parent can refetch messages. */
onConversationScored?: (conversationId: string, scores: BackendScore[]) => void
}

export default function ConversationPanel({
Expand All @@ -45,12 +49,14 @@ export default function ConversationPanel({
onClose,
lockedReason,
refreshKey,
onConversationScored,
}: ConversationPanelProps) {
const styles = useConversationPanelStyles()
const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [mainConversationId, setMainConversationId] = useState<string | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [scoreTarget, setScoreTarget] = useState<ScoreTarget | null>(null)

const fetchConversations = useCallback(async () => {
if (!attackResultId) {
Expand Down Expand Up @@ -202,6 +208,25 @@ export default function ConversationPanel({
style={{ minWidth: 'auto', padding: '2px' }}
/>
</Tooltip>
<Tooltip content="Score this conversation" relationship="description">
<Button
appearance="subtle"
size="small"
icon={<DataBarVerticalRegular />}
disabled={!attackResultId}
onClick={(e) => {
e.stopPropagation()
if (!attackResultId) return
setScoreTarget({
kind: 'conversation',
attackResultId,
conversationId: conv.conversation_id,
})
}}
data-testid={`score-btn-${conv.conversation_id}`}
style={{ minWidth: 'auto', padding: '2px' }}
/>
</Tooltip>
<Badge appearance="tint" size="small">
{conv.message_count}
</Badge>
Expand All @@ -216,6 +241,18 @@ export default function ConversationPanel({
)
})}
</div>
<ScoreDialog
open={scoreTarget != null}
target={scoreTarget}
onClose={() => setScoreTarget(null)}
onScored={(scores) => {
const conversationId = scoreTarget?.conversationId
setScoreTarget(null)
if (conversationId) {
onConversationScored?.(conversationId, scores)
}
}}
/>
</div>
)
}
Expand Down
Loading
Loading