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
14 changes: 11 additions & 3 deletions frontend/src/components/Chat/ChatWindow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export default function ChatWindow({
setIsPanelOpen(true)
}
}, [attackResultId, relatedConversationCount])
// Set by panel click to bypass the in-flight guard on the next useEffect cycle.
// This lets users switch to a sending conversation while still protecting
// optimistic messages when handleSend internally updates activeConversationId.
const forceLoadRef = useRef(false)
// Always-current ref of the conversation being viewed so async callbacks can
// check whether the user navigated away while a request was in-flight.
const viewedConvRef = useRef(activeConversationId ?? conversationId)
Expand Down Expand Up @@ -126,9 +130,12 @@ export default function ChatWindow({
// Reload messages when activeConversationId changes
useEffect(() => {
if (!attackResultId || !activeConversationId) { return }
// Skip loading if a send is already in-flight for this conversation —
// the send handler will update messages when it completes.
if (sendingConvIdsRef.current.has(activeConversationId)) { return }
// Allow user-initiated switches (forceLoadRef), but skip re-loading when
// handleSend internally updated activeConversationId during an in-flight
// send — the optimistic messages are already displayed.
const force = forceLoadRef.current
forceLoadRef.current = false
if (!force && sendingConvIdsRef.current.has(activeConversationId)) { return }
loadConversation(attackResultId, activeConversationId)
}, [activeConversationId, attackResultId, loadConversation])

Expand All @@ -143,6 +150,7 @@ export default function ChatWindow({
// Handle conversation selection from the panel
// For a different ID the useEffect handles loading; for same ID force a refresh
const handlePanelSelectConversation = useCallback((convId: string) => {
forceLoadRef.current = true
onSelectConversation(convId)
if (convId === activeConversationId && attackResultId) {
loadConversation(attackResultId, convId)
Expand Down
13 changes: 9 additions & 4 deletions pyrit/backend/services/attack_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,9 @@ async def get_conversations_async(self, *, attack_result_id: str) -> Optional[At
for conv_id in active_conv_ids:
stats = stats_map.get(conv_id)
created_at = stats.created_at if stats else None
# SQLite returns naive datetimes — normalize to UTC (same pattern as _ensure_utc)
if created_at is not None and created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
conversations.append(
ConversationSummary(
conversation_id=conv_id,
Expand All @@ -399,10 +402,12 @@ async def get_conversations_async(self, *, attack_result_id: str) -> Optional[At
)
)

# Sort all conversations by created_at (earliest first, None last)
conversations.sort(
key=lambda c: (c.created_at is None, c.created_at or datetime.min.replace(tzinfo=timezone.utc))
)
# Sort conversations by created_at (earliest first). In-flight conversations
# have no stored messages yet so created_at is None — treat them as the most
# recent (they were just created) so they sort after older conversations
# instead of jumping to an arbitrary position.
now = datetime.now(timezone.utc)
conversations.sort(key=lambda c: c.created_at or now)

return AttackConversationsResponse(
attack_result_id=attack_result_id,
Expand Down
Loading