valhalla / frontend /src /components /AgentWindow.jsx
Dontcryx_07
final
d660fa9
Raw
History Blame Contribute Delete
10.3 kB
/**
* 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 (
<Draggable
nodeRef={nodeRef}
position={position}
onDrag={(_event, dragData) => setPosition({ x: dragData.x, y: dragData.y })}
handle=".agent-window-drag-handle"
cancel=".agent-window-toggle, .agent-window-title"
bounds="parent"
>
<div
ref={nodeRef}
style={{
position: "absolute",
width: expanded ? 260 : 214,
background: "rgba(10,10,10,0.88)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
border: expanded
? `1px solid ${data.color}`
: paused
? `1px solid ${data.color}40`
: "1px solid rgba(212,160,74,0.10)",
borderRadius: 6,
padding: "10px 12px",
color: "#d0d0da",
fontFamily: "'Outfit', sans-serif",
userSelect: "none",
zIndex: expanded ? 300 + expandedLayer : paused ? 100 : 10,
boxShadow: expanded
? `0 0 28px ${data.color}55, 0 4px 24px rgba(0,0,0,0.5)`
: paused
? `0 0 24px ${data.color}25, 0 4px 24px rgba(0,0,0,0.5)`
: "0 2px 16px rgba(0,0,0,0.4)",
transition: "box-shadow 0.3s, border-color 0.3s",
}}
>
<div className="agent-window-drag-handle" style={{ display: "flex", alignItems: "flex-start", gap: 6, minHeight: 22, cursor: "grab" }}>
<span
className="agent-window-drag-grip"
aria-label={`Drag ${data.name}'s inspector`}
title="Drag inspector"
style={{ color: "#6b6b78", fontFamily: "'Space Mono', monospace", fontSize: 11, lineHeight: "20px" }}
>
</span>
<button
className="agent-window-title"
type="button"
onClick={onToggle}
aria-expanded={expanded}
title={expanded ? "Collapse details" : "Show details"}
style={{
flex: 1, minWidth: 0, border: 0, padding: 0, margin: 0,
background: "transparent", textAlign: "left", cursor: "pointer", color: "inherit",
}}
>
<WindowHeader name={data.name} color={data.color} />
</button>
<button
className="agent-window-toggle"
type="button"
onClick={(event) => {
event.stopPropagation();
onToggle();
}}
aria-label={expanded ? `Collapse ${data.name}'s details` : `Show ${data.name}'s details`}
aria-expanded={expanded}
title={expanded ? "Collapse details" : "Show details"}
style={{
flex: "0 0 auto",
marginTop: 0,
background: "rgba(212,160,74,0.06)",
border: "1px solid rgba(212,160,74,0.22)",
borderRadius: 3,
color: "#d4a04a",
cursor: "pointer",
fontSize: 13,
fontFamily: "'Space Mono', monospace",
transition: "color 0.2s",
width: 20,
height: 18,
lineHeight: 1,
}}
>
{expanded ? "−" : "+"}
</button>
</div>
{!expanded ? (
<div style={{
color: "#9b9ba8", fontSize: 10, lineHeight: 1.35, paddingRight: 5,
whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis",
}} title={`${data.position?.location_id || "Unknown"} — ${data.activity || "Idle"}`}>
<span style={{ color: "#6b6b78", fontFamily: "'Space Mono', monospace", fontSize: 8 }}>
{data.position?.location_id || "UNKNOWN"}
</span>
<span style={{ color: "#5a5a66", margin: "0 5px" }}>·</span>
{data.activity || "Idle"}
</div>
) : (
<>
{/* Location */}
<div style={{
fontSize: 9,
fontFamily: "'Space Mono', monospace",
color: "#6b6b78",
marginBottom: 4,
letterSpacing: 0.3,
}}>
{locationLabel ? `${locationLabel} ${pos}` : pos}
</div>
<ActionDetail action={action} />
{action?.event_id && (
<div style={{
marginTop: 5, color: "#6f9fd1", fontSize: 8, letterSpacing: 0.7,
fontFamily: "'Space Mono', monospace", textTransform: "uppercase",
}}>
Campus event · {action.event_id}
</div>
)}
{/* Energy bar */}
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 9, color: "#6b6b78", fontFamily: "'Space Mono', monospace", marginBottom: 2 }}>
ENERGY · {Math.round((data.energy_level ?? 0) * 100)}%
</div>
<div style={{ height: 6, background: "rgba(255,255,255,0.06)", borderRadius: 3, overflow: "hidden" }}>
<div style={{
height: "100%",
width: `${Math.round((data.energy_level ?? 0) * 100)}%`,
background: "linear-gradient(90deg, #ff6b6b, #ffd43b, #51cf66)",
borderRadius: 3,
transition: "width 0.3s",
}} />
</div>
</div>
{/* Emotion label */}
<div style={{ fontSize: 9, color: "#6b6b78", fontFamily: "'Space Mono', monospace", marginBottom: 6 }}>
EMOTION: <span style={{ color: "#d0d0da" }}>{emotionLabel(data.emotion_state)} · {Math.round((data.emotion_state ?? 0.5) * 100)}%</span>
</div>
{paused && !conversation && (
<div style={{
fontSize: 9,
color: data.color,
marginTop: 4,
fontStyle: "italic",
letterSpacing: 0.5,
textTransform: "uppercase",
}}>
Paused
</div>
)}
{/* The panel unmounts when the next non-chat action starts. */}
{showConversation && (
<ChatPanel
conversation={conversation}
selfId={data.name}
color={data.color}
revealedCount={revealedCounts[conversationId] ?? conversation.messages.length}
/>
)}
</>
)}
</div>
</Draggable>
);
}