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:
parent
19f43dbe26
commit
7dacb86d00
3 changed files with 188 additions and 32 deletions
|
|
@ -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')}"
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue