From 7dacb86d005f69864c3e4fdc923cffd5e873a3c9 Mon Sep 17 00:00:00 2001 From: Vadym Samoilenko Date: Mon, 25 May 2026 20:27:50 +0100 Subject: [PATCH] fix(focus-group): reset moderator_position on restart/duplicate + editable participants - Restart endpoint now $unsets moderator_position, completion_reason, autonomous_ended_at, autonomous_started_at so the AI doesn't immediately conclude on the next run (was reading stale "guide complete" position) - Duplicate endpoint excludes same fields from the copy for the same reason - ParticipantPanel: add isEditable mode with remove buttons and searchable "Add Participant" dialog (only active when status=new) - FocusGroupSession: wire allPersonas state + add/remove handlers, pass isEditable={status === 'new'} to ParticipantPanel Co-Authored-By: Claude Sonnet 4.6 --- backend/app/routes/focus_groups.py | 18 +- .../focus-group-session/ParticipantPanel.tsx | 177 +++++++++++++++--- src/pages/FocusGroupSession.tsx | 25 ++- 3 files changed, 188 insertions(+), 32 deletions(-) diff --git a/backend/app/routes/focus_groups.py b/backend/app/routes/focus_groups.py index def48c2c..48f3440c 100755 --- a/backend/app/routes/focus_groups.py +++ b/backend/app/routes/focus_groups.py @@ -473,10 +473,18 @@ async def restart_focus_group(focus_group_id): for collection_name, field_name in session_collections: await getattr(db, collection_name).delete_many({field_name: focus_group_id}) - # Reset status back to new + # Reset status and moderator position back to initial state await db.focus_groups.update_one( {"_id": ObjectId(focus_group_id)}, - {"$set": {"status": "new", "updated_at": datetime.datetime.now(datetime.timezone.utc)}} + { + "$set": {"status": "new", "updated_at": datetime.datetime.now(datetime.timezone.utc)}, + "$unset": { + "moderator_position": "", + "completion_reason": "", + "autonomous_ended_at": "", + "autonomous_started_at": "", + } + } ) updated = await FocusGroup.find_by_id(focus_group_id) @@ -502,7 +510,11 @@ async def duplicate_focus_group(focus_group_id): # Build copy — preserve config, strip session-specific state copy_data = { k: v for k, v in focus_group.items() - if k not in ('_id', 'id', 'created_at', 'updated_at', 'status') + if k not in ( + '_id', 'id', 'created_at', 'updated_at', 'status', + 'moderator_position', 'completion_reason', + 'autonomous_ended_at', 'autonomous_started_at', + ) } copy_data['name'] = f"Copy of {focus_group.get('name', 'Focus Group')}" diff --git a/src/components/focus-group-session/ParticipantPanel.tsx b/src/components/focus-group-session/ParticipantPanel.tsx index 57aa1e5c..daec6998 100755 --- a/src/components/focus-group-session/ParticipantPanel.tsx +++ b/src/components/focus-group-session/ParticipantPanel.tsx @@ -1,39 +1,88 @@ -import { UserCircle, Bot, Users, Check } from 'lucide-react'; +import { useState } from 'react'; +import { Bot, Users, Check, X, UserPlus, Search } from 'lucide-react'; import { Persona } from '@/types/persona'; import { useNavigate, useParams } from 'react-router-dom'; import { getPersonaAvatarSrc } from '@/utils/avatarUtils'; import { useNavigation } from '@/contexts/NavigationContext'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; interface ParticipantPanelProps { participants: Persona[]; selectedParticipantIds: string[]; onToggleParticipantFilter: (participantId: string) => void; + isEditable?: boolean; + allPersonas?: Persona[]; + onAddParticipant?: (personaId: string) => Promise; + onRemoveParticipant?: (personaId: string) => Promise; } -const ParticipantPanel = ({ participants, selectedParticipantIds, onToggleParticipantFilter }: ParticipantPanelProps) => { +const ParticipantPanel = ({ + participants, + selectedParticipantIds, + onToggleParticipantFilter, + isEditable = false, + allPersonas = [], + onAddParticipant, + onRemoveParticipant, +}: ParticipantPanelProps) => { const navigate = useNavigate(); const { id: focusGroupId } = useParams<{ id: string }>(); const { setPreviousRoute } = useNavigation(); + const [showAddDialog, setShowAddDialog] = useState(false); + const [search, setSearch] = useState(''); + const [loading, setLoading] = useState(null); const handleAvatarClick = (participant: Persona) => { + if (isEditable) return; const personaId = participant.id || participant._id; if (personaId && focusGroupId) { - // Set navigation context so back button returns to focus group session - setPreviousRoute(`/focus-groups/${focusGroupId}`, { - focusGroupId: focusGroupId - }); + setPreviousRoute(`/focus-groups/${focusGroupId}`, { focusGroupId }); navigate(`/personas/${personaId}`); } }; const handleNameClick = (participant: Persona) => { + if (isEditable) return; const personaId = participant.id || participant._id; - if (personaId) { - onToggleParticipantFilter(personaId); + if (personaId) onToggleParticipantFilter(personaId); + }; + + const handleRemove = async (participant: Persona) => { + const personaId = participant.id || participant._id; + if (!personaId || !onRemoveParticipant) return; + setLoading(personaId); + try { + await onRemoveParticipant(personaId); + } finally { + setLoading(null); } }; + const handleAdd = async (persona: Persona) => { + const personaId = persona.id || persona._id; + if (!personaId || !onAddParticipant) return; + setLoading(personaId); + try { + await onAddParticipant(personaId); + } finally { + setLoading(null); + } + }; + + const currentIds = new Set(participants.map(p => p.id || p._id)); + const available = allPersonas.filter(p => { + const pid = p.id || p._id; + if (currentIds.has(pid)) return false; + if (!search) return true; + return ( + p.name?.toLowerCase().includes(search.toLowerCase()) || + p.occupation?.toLowerCase().includes(search.toLowerCase()) + ); + }); + return (
@@ -48,49 +97,123 @@ const ParticipantPanel = ({ participants, selectedParticipantIds, onTogglePartic

Session facilitator

- + {participants.map(participant => { const participantId = participant.id || participant._id; const isSelected = selectedParticipantIds.includes(participantId); - + const isRemoving = loading === participantId; + return ( -
-
handleAvatarClick(participant)} - title={`View ${participant.name}'s profile`} + title={isEditable ? undefined : `View ${participant.name}'s profile`} > - {participant.name}
-
+
-

handleNameClick(participant)} - title={`Filter to show only ${participant.name}'s messages`} + title={isEditable ? undefined : `Filter to show only ${participant.name}'s messages`} > {participant.name}

- {isSelected && ( - + {isSelected && !isEditable && ( + )}
-

{participant.occupation}

+

{participant.occupation}

+ {isEditable && ( + + )}
); })} + + {isEditable && ( + + )}
+ + + + + Add Participant + +
+ + setSearch(e.target.value)} + className="pl-8" + autoFocus + /> +
+
+ {available.length === 0 && ( +

+ {search ? 'No matches found' : 'All personas already added'} +

+ )} + {available.map(persona => { + const pid = persona.id || persona._id; + const isAdding = loading === pid; + return ( +
!isAdding && handleAdd(persona)} + > + {persona.name} +
+

{persona.name}

+

{persona.occupation}

+
+ {isAdding ? ( + Adding… + ) : ( + + )} +
+ ); + })} +
+
+
); }; diff --git a/src/pages/FocusGroupSession.tsx b/src/pages/FocusGroupSession.tsx index 89c15a8c..0236d623 100755 --- a/src/pages/FocusGroupSession.tsx +++ b/src/pages/FocusGroupSession.tsx @@ -57,6 +57,7 @@ const FocusGroupSession = () => { const [themes, setThemes] = useState([]); const [focusGroup, setFocusGroup] = useState(null); const [participants, setParticipants] = useState([]); + const [allPersonas, setAllPersonas] = useState([]); const [activeTab, setActiveTab] = useState('chat'); const [moderatorStatus, setModeratorStatus] = useState(null); const [showAutonomousDashboard, setShowAutonomousDashboard] = useState(false); @@ -724,6 +725,7 @@ const FocusGroupSession = () => { try { const allPersonasResponse = await personasApi.getAll(); const allPersonas = allPersonasResponse.data || []; + setAllPersonas(allPersonas); const response = await focusGroupsApi.getById(id); @@ -835,7 +837,9 @@ const FocusGroupSession = () => { const fetchAllPersonas = async () => { try { const response = await personasApi.getAll(); - return response.data || []; + const data = response.data || []; + setAllPersonas(data); + return data; } catch (error) { console.error("Error fetching personas:", error); return []; @@ -1127,6 +1131,19 @@ const FocusGroupSession = () => { } }; + const handleAddParticipant = async (personaId: string) => { + if (!id) return; + await focusGroupsApi.addParticipant(id, personaId); + const persona = allPersonas.find(p => (p.id || p._id) === personaId); + if (persona) setParticipants(prev => [...prev, persona]); + }; + + const handleRemoveParticipant = async (personaId: string) => { + if (!id) return; + await focusGroupsApi.removeParticipant(id, personaId); + setParticipants(prev => prev.filter(p => (p.id || p._id) !== personaId)); + }; + // This function is kept for backward compatibility with other features // but we now use the direct AI response generation in DiscussionPanel const addModeratorMessage = async () => { @@ -1989,10 +2006,14 @@ const FocusGroupSession = () => { />
-