/** * AgentWindow — per-agent draggable inspector card. * * Compact card by default; expanded view shows location, current action * (via ActionDetail), energy/emotion gauges, pause state, and the agent's * live conversation transcript (ChatPanel). * * Architecture: one instance per agent, laid out by App.jsx; fed from the * shared simulation snapshot. * * Design: windows are draggable (react-draggable) so multiple agents can * be inspected simultaneously. */ import { useRef, useState, useEffect } from "react"; import Draggable from "react-draggable"; import WindowHeader from "./WindowHeader"; import ActionDetail from "./ActionDetail"; import ChatPanel from "./ChatPanel"; function emotionLabel(value) { if (value == null) return "Neutral"; if (value >= 0.84) return "Elated"; if (value >= 0.68) return "Upbeat"; if (value >= 0.56) return "Content"; if (value >= 0.44) return "Neutral"; if (value >= 0.30) return "Low"; if (value >= 0.16) return "Stressed"; return "Overwhelmed"; } export default function AgentWindow({ agentId, data, speed, defaultPosition, expanded = false, expandedLayer = 0, onToggle }) { const nodeRef = useRef(null); const [position, setPosition] = useState(defaultPosition); const [revealedCounts, setRevealedCounts] = useState({}); const action = data.current_action; const conversation = data.conversation; const paused = data.paused; const conversationId = conversation ? `${conversation.partner_id || conversation.partner_name}_${conversation.started_tick ?? "pending"}` : null; // card's lifetime. The backend clears this state when the simulated chat // finishes; rendering only an active/generating conversation automatically // collapses the chat panel as the agent starts their next task. const showConversation = Boolean( conversation?.messages?.length && ["generating", "active"].includes(conversation.status) ); // Staged message reveal: when a new conversation arrives, reveal messages // one-by-one based on duration / message count. const realMsPerSimMinute = speed?.real_ms_per_sim_minute || 40000; useEffect(() => { if (!conversation?.messages?.length || !conversation.duration_minutes) return; const key = conversationId; const msgs = conversation.messages.length; const totalMs = conversation.duration_minutes * realMsPerSimMinute; const delayPerMsg = totalMs / msgs; setRevealedCounts((prev) => ({ ...prev, [key]: 0 })); let i = 0; const timer = setInterval(() => { i++; setRevealedCounts((prev) => { if ((prev[key] || 0) >= msgs) return prev; return { ...prev, [key]: i }; }); if (i >= msgs) clearInterval(timer); }, delayPerMsg); return () => clearInterval(timer); }, [conversationId, conversation?.messages?.length, conversation?.duration_minutes]); const locationLabel = action?.action_type === "move" ? `EN ROUTE → ${action.location_id || "destination"}` : data.position?.location_id || null; const pos = data.position ? `(${Math.round(data.position.x)}, ${Math.round(data.position.y)})` : null; // `react-draggable` bounds movement using the card's size when the drag // starts. A compact card can therefore be placed near an edge and become // partly off-screen when its details increase the size. Clamp the saved // position after every expand/collapse transition as a second boundary. useEffect(() => { const node = nodeRef.current; if (!node) return; const maxX = Math.max(0, window.innerWidth - node.offsetWidth); const maxY = Math.max(0, window.innerHeight - node.offsetHeight); setPosition((previous) => ({ x: Math.min(Math.max(0, previous.x), maxX), y: Math.min(Math.max(0, previous.y), maxY), })); }, [expanded]); return ( setPosition({ x: dragData.x, y: dragData.y })} handle=".agent-window-drag-handle" cancel=".agent-window-toggle, .agent-window-title" bounds="parent" >
{!expanded ? (
{data.position?.location_id || "UNKNOWN"} · {data.activity || "Idle"}
) : ( <> {/* Location */}
{locationLabel ? `${locationLabel} ${pos}` : pos}
{action?.event_id && (
Campus event · {action.event_id}
)} {/* Energy bar */}
ENERGY · {Math.round((data.energy_level ?? 0) * 100)}%
{/* Emotion label */}
EMOTION: {emotionLabel(data.emotion_state)} · {Math.round((data.emotion_state ?? 0.5) * 100)}%
{paused && !conversation && (
Paused
)} {/* The panel unmounts when the next non-chat action starts. */} {showConversation && ( )} )}
); }