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 <noreply@anthropic.com>
This commit is contained in:
Vadym Samoilenko 2026-05-25 20:27:50 +01:00
parent 19f43dbe26
commit 7dacb86d00
3 changed files with 188 additions and 32 deletions

View file

@ -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')}"

View file

@ -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<void>;
onRemoveParticipant?: (personaId: string) => Promise<void>;
}
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<string | null>(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 (
<div className="w-full lg:w-64 shrink-0">
<div className="glass-panel rounded-xl p-4">
@ -48,49 +97,123 @@ const ParticipantPanel = ({ participants, selectedParticipantIds, onTogglePartic
<p className="text-xs text-slate-500">Session facilitator</p>
</div>
</div>
{participants.map(participant => {
const participantId = participant.id || participant._id;
const isSelected = selectedParticipantIds.includes(participantId);
const isRemoving = loading === participantId;
return (
<div
key={participant.id}
<div
key={participantId}
className={`flex items-center p-2 rounded-lg transition-colors ${
isSelected ? 'bg-blue-50 border border-blue-200' : 'hover:bg-slate-100'
isSelected && !isEditable ? 'bg-blue-50 border border-blue-200' : 'hover:bg-slate-100'
}`}
>
<div
className="cursor-pointer mr-3"
<div
className={`mr-3 ${isEditable ? 'cursor-default' : 'cursor-pointer'}`}
onClick={() => handleAvatarClick(participant)}
title={`View ${participant.name}'s profile`}
title={isEditable ? undefined : `View ${participant.name}'s profile`}
>
<img
src={getPersonaAvatarSrc(participant)}
alt={participant.name}
className="h-8 w-8 rounded-full object-cover"
<img
src={getPersonaAvatarSrc(participant)}
alt={participant.name}
className="h-8 w-8 rounded-full object-cover"
/>
</div>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center">
<p
className="font-medium cursor-pointer hover:text-blue-600 transition-colors"
<p
className={`font-medium truncate ${isEditable ? '' : 'cursor-pointer hover:text-blue-600 transition-colors'}`}
onClick={() => handleNameClick(participant)}
title={`Filter to show only ${participant.name}'s messages`}
title={isEditable ? undefined : `Filter to show only ${participant.name}'s messages`}
>
{participant.name}
</p>
{isSelected && (
<Check className="h-4 w-4 text-blue-600 ml-2" />
{isSelected && !isEditable && (
<Check className="h-4 w-4 text-blue-600 ml-2 shrink-0" />
)}
</div>
<p className="text-xs text-slate-500">{participant.occupation}</p>
<p className="text-xs text-slate-500 truncate">{participant.occupation}</p>
</div>
{isEditable && (
<button
onClick={() => handleRemove(participant)}
disabled={isRemoving}
className="ml-2 shrink-0 text-slate-400 hover:text-red-500 transition-colors disabled:opacity-40"
title={`Remove ${participant.name}`}
>
<X className="h-4 w-4" />
</button>
)}
</div>
);
})}
{isEditable && (
<Button
variant="outline"
size="sm"
className="w-full mt-1"
onClick={() => { setSearch(''); setShowAddDialog(true); }}
>
<UserPlus className="h-4 w-4 mr-2" />
Add Participant
</Button>
)}
</div>
</div>
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>Add Participant</DialogTitle>
</DialogHeader>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-slate-400" />
<Input
placeholder="Search personas…"
value={search}
onChange={e => setSearch(e.target.value)}
className="pl-8"
autoFocus
/>
</div>
<div className="space-y-2 max-h-72 overflow-y-auto">
{available.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{search ? 'No matches found' : 'All personas already added'}
</p>
)}
{available.map(persona => {
const pid = persona.id || persona._id;
const isAdding = loading === pid;
return (
<div
key={pid}
className="flex items-center gap-3 p-2 rounded-lg hover:bg-slate-100 cursor-pointer"
onClick={() => !isAdding && handleAdd(persona)}
>
<img
src={getPersonaAvatarSrc(persona)}
alt={persona.name}
className="h-8 w-8 rounded-full object-cover shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="font-medium text-sm truncate">{persona.name}</p>
<p className="text-xs text-slate-500 truncate">{persona.occupation}</p>
</div>
{isAdding ? (
<span className="text-xs text-slate-400">Adding</span>
) : (
<UserPlus className="h-4 w-4 text-slate-400 shrink-0" />
)}
</div>
);
})}
</div>
</DialogContent>
</Dialog>
</div>
);
};

View file

@ -57,6 +57,7 @@ const FocusGroupSession = () => {
const [themes, setThemes] = useState<Theme[]>([]);
const [focusGroup, setFocusGroup] = useState<FocusGroup | null>(null);
const [participants, setParticipants] = useState<Persona[]>([]);
const [allPersonas, setAllPersonas] = useState<Persona[]>([]);
const [activeTab, setActiveTab] = useState('chat');
const [moderatorStatus, setModeratorStatus] = useState<any>(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 = () => {
/>
<div className="flex flex-col lg:flex-row gap-6 lg:h-[calc(100vh-12rem)]">
<ParticipantPanel
<ParticipantPanel
participants={participants}
selectedParticipantIds={selectedParticipantIds}
onToggleParticipantFilter={toggleParticipantFilter}
isEditable={focusGroup?.status === 'new'}
allPersonas={allPersonas}
onAddParticipant={handleAddParticipant}
onRemoveParticipant={handleRemoveParticipant}
/>
<div className="flex-1 flex flex-col">