'use client'; /** * AgentRosterPanel — who is in this classroom, and what each of them is like. * * Course-level content, not an app setting: the roster lives on the stage * document (`stage.generatedAgentConfigs`), and every edit here goes straight * into it through `useAgentRoster`. It is mounted from `RosterDialog`, opened * from the edit dock's global bar. * * One card per member, collapsed to a line and expanded to an editor. The lead * teacher is first and cannot be removed (the last-teacher guard lives in * `agent-ops`); AI classmates below it reorder and can leave the class. * * Colours: the chrome is theme tokens, so the panel reads correctly in both * themes. The one exception is each classmate's OWN colour, which is data on the * agent and therefore stays an inline value. */ import { type ReactNode, type Ref, useCallback, useEffect, useImperativeHandle, useLayoutEffect, useRef, useState, } from 'react'; import { Camera, ChevronDown, ChevronUp, Redo2, Undo2, UserMinus, UserPlus } from 'lucide-react'; import { cn } from '@/lib/utils/cn'; import { useI18n } from '@/lib/hooks/use-i18n'; import { useStageStore } from '@/lib/store/stage'; import type { GeneratedAgentConfig } from '@/lib/types/stage'; import { useAgentRoster } from './useAgentRoster'; import { AvatarPicker } from './AvatarPicker'; const PERSONA_MAX = 2000; interface RosterDraft { readonly agentId: string; readonly patch: Partial; } type RegisterDraft = (key: string, read: () => RosterDraft | null) => () => void; // ─── Avatar with camera overlay ────────────────────────────────────────────── interface AvatarWithOverlayProps { readonly agent: GeneratedAgentConfig; readonly size: number; readonly ringColor: string; readonly onPickerOpen: () => void; } function AvatarWithOverlay({ agent, size, ringColor, onPickerOpen }: AvatarWithOverlayProps) { const [hovering, setHovering] = useState(false); return (
setHovering(true)} onMouseLeave={() => setHovering(false)} onClick={(e) => { e.stopPropagation(); onPickerOpen(); }} > {/* Avatars are static public paths; the roster needs no image optimizer. */} {agent.name} {hovering && (
)}
); } // ─── Inline editable name ──────────────────────────────────────────────────── interface EditableNameProps { readonly agentId: string; readonly draftKey: string; readonly value: string; readonly onCommit: (v: string) => void; readonly registerDraft: RegisterDraft; readonly className?: string; } function EditableName({ agentId, draftKey, value, onCommit, registerDraft, className, }: EditableNameProps) { const ref = useRef(null); const draftValueRef = useRef(value); const focusedRef = useRef(false); useEffect(() => { if (!focusedRef.current) draftValueRef.current = value; }, [value]); const handleBlur = useCallback(() => { const text = ref.current?.textContent?.trim() ?? ''; if (text && text !== value) onCommit(text); else if (ref.current) ref.current.textContent = value; }, [value, onCommit]); const readDraft = useCallback((): RosterDraft | null => { // Immediate Radix closes can still read the live DOM; controlled owner // closes may already have detached it, in which case the input snapshot is // the durable owner-side copy. const name = (ref.current?.textContent ?? draftValueRef.current).trim(); return name && name !== value ? { agentId, patch: { name } } : null; }, [agentId, value]); useEffect(() => registerDraft(draftKey, readDraft), [draftKey, readDraft, registerDraft]); return ( { draftValueRef.current = event.currentTarget.textContent ?? ''; }} onFocus={() => { focusedRef.current = true; }} onBlur={() => { focusedRef.current = false; handleBlur(); }} onClick={(e) => e.stopPropagation()} className={cn( 'cursor-text rounded-[3px] outline-none', 'hover:underline hover:decoration-primary/60 hover:decoration-dashed', 'focus:shadow-[0_0_0_2px_var(--ring)]', className, )} style={{ minWidth: 10 }} > {value} ); } // ─── Persona textarea ───────────────────────────────────────────────────────── interface PersonaEditorProps { readonly agentId: string; readonly value: string; readonly onUpdate: (id: string, persona: string) => void; readonly registerDraft: RegisterDraft; } function PersonaEditor({ agentId, value, onUpdate, registerDraft }: PersonaEditorProps) { const { t } = useI18n(); const [prevValue, setPrevValue] = useState(value); const [draft, setDraft] = useState(value); const [focused, setFocused] = useState(false); // Sync draft when value changes externally (e.g. undo/redo), but only when // not focused — avoids clobbering in-progress typing. Render-time state // update: React re-renders immediately with the new draft before painting. if (prevValue !== value && !focused) { setPrevValue(value); setDraft(value); } const handleChange = (e: React.ChangeEvent) => { setDraft(e.target.value.slice(0, PERSONA_MAX)); }; const handleBlur = useCallback(() => { setFocused(false); if (draft !== value) onUpdate(agentId, draft); }, [agentId, draft, onUpdate, value]); const readDraft = useCallback( (): RosterDraft | null => (draft !== value ? { agentId, patch: { persona: draft } } : null), [agentId, draft, value], ); useEffect( () => registerDraft(`${agentId}:persona`, readDraft), [agentId, readDraft, registerDraft], ); return (
{t('edit.roster.personaLabel')}