diff --git a/src/components/monitor/ActionGraph.tsx b/src/components/monitor/ActionGraph.tsx index 4f6e58c..2efc8bd 100644 --- a/src/components/monitor/ActionGraph.tsx +++ b/src/components/monitor/ActionGraph.tsx @@ -9,9 +9,11 @@ import { type Node, type Edge, type NodeTypes, + type NodeChange, MarkerType, ReactFlowProvider, } from '@xyflow/react' +import { LayoutGrid } from 'lucide-react' import '@xyflow/react/dist/style.css' import { SessionNode } from './SessionNode' import { ActionNode } from './ActionNode' @@ -91,6 +93,7 @@ function ActionGraphInner({ const prevNodeIdsRef = useRef>(new Set()) const nodePositionsRef = useRef>(new Map()) + const pinnedPositions = useRef>(new Map()) const animationFrameRef = useRef(undefined) const timeoutRef = useRef(undefined) @@ -370,7 +373,25 @@ function ActionGraphInner({ return [...layoutedNodes, chaserNode] }, []) - const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes) + const [nodes, setNodes, rawOnNodesChange] = useNodesState(initialNodes) + + // Intercept node changes to detect drag-end and pin positions + const onNodesChange = useCallback( + (changes: NodeChange[]) => { + for (const change of changes) { + if ( + change.type === 'position' && + 'dragging' in change && + change.dragging === false && + change.position + ) { + pinnedPositions.current.set(change.id, { ...change.position }) + } + } + rawOnNodesChange(changes) + }, + [rawOnNodesChange] + ) // Handle crab click - jump animation (defined after setNodes) const handleCrabClick = useCallback(() => { @@ -642,16 +663,22 @@ function ActionGraphInner({ } }, [layoutedNodes, setNodes, handleCrabClick]) - // Update layout nodes when they change (preserve chaser) + // Update layout nodes when they change (preserve chaser + pinned positions) useEffect(() => { setNodes((nds) => { + const pinned = pinnedPositions.current + const mergedNodes = layoutedNodes.map((n) => { + const pin = pinned.get(n.id) + return pin ? { ...n, position: pin } : n + }) + const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID) if (chaserNode) { - return [...layoutedNodes, chaserNode] + return [...mergedNodes, chaserNode] } const crab = crabRef.current return [ - ...layoutedNodes, + ...mergedNodes, { id: CHASER_CRAB_ID, type: 'chaserCrab', @@ -670,6 +697,18 @@ function ActionGraphInner({ setEdges(layoutedEdges) }, [layoutedNodes, layoutedEdges, setNodes, setEdges, handleCrabClick]) + // Re-organize: clear pinned positions and re-apply layout + const handleReorganize = useCallback(() => { + pinnedPositions.current.clear() + setNodes((nds) => { + const chaserNode = nds.find((n) => n.id === CHASER_CRAB_ID) + if (chaserNode) { + return [...layoutedNodes, chaserNode] + } + return [...layoutedNodes] + }) + }, [layoutedNodes, setNodes]) + // Cleanup useEffect(() => { return () => { @@ -711,6 +750,15 @@ function ActionGraphInner({ +
+ +
{ if (node.type === 'crab') return '#ef4444' diff --git a/src/components/monitor/SessionList.tsx b/src/components/monitor/SessionList.tsx index 94b153a..6f1eb62 100644 --- a/src/components/monitor/SessionList.tsx +++ b/src/components/monitor/SessionList.tsx @@ -1,9 +1,13 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { motion, AnimatePresence } from "framer-motion"; -import { Users, ChevronLeft, ChevronRight, Github } from "lucide-react"; +import { Users, ChevronLeft, ChevronRight, ChevronDown, Github } from "lucide-react"; import { StatusIndicator } from "./StatusIndicator"; import type { MonitorSession } from "~/integrations/clawdbot"; +function isSubagent(session: MonitorSession): boolean { + return Boolean(session.spawnedBy) || session.platform === "subagent" || session.key.includes("subagent"); +} + function XIcon({ size = 14, className, @@ -41,6 +45,54 @@ const platformEmoji: Record = { slack: "💼", }; +function SubagentItem({ + session, + selected, + collapsed, + onSelect, +}: { + session: MonitorSession; + selected: boolean; + collapsed: boolean; + onSelect: (key: string) => void; +}) { + return ( + onSelect(session.key)} + className={`w-full text-left border-b border-shell-800/50 transition-all duration-150 group ${ + collapsed ? "p-2" : "py-2 pr-3 pl-6" + } ${ + selected + ? "bg-neon-cyan/5 border-l-2 border-l-neon-cyan" + : "hover:bg-shell-800/30 border-l-2 border-l-transparent" + }`} + title={collapsed ? "subagent" : undefined} + > + {collapsed ? ( +
+ 🤖 + +
+ ) : ( + <> +
+ subagent +
+
+ 🤖 + + {session.recipient} + + +
+ + )} +
+ ); +} + export function SessionList({ sessions, selectedKey, @@ -50,10 +102,12 @@ export function SessionList({ }: SessionListProps) { const [filter, setFilter] = useState(""); const [platformFilter, setPlatformFilter] = useState(null); + const [collapsedGroups, setCollapsedGroups] = useState>(new Set()); - const platforms = [...new Set(sessions.map((s) => s.platform))]; + const parentSessions = sessions.filter((s) => !isSubagent(s)); + const platforms = [...new Set(parentSessions.map((s) => s.platform))]; - const filteredSessions = sessions.filter((session) => { + const filteredParents = parentSessions.filter((session) => { const matchesText = !filter || session.recipient.toLowerCase().includes(filter.toLowerCase()) || @@ -64,12 +118,45 @@ export function SessionList({ }); // Sort: active first, then by lastActivityAt - const sortedSessions = [...filteredSessions].sort((a, b) => { + const sortedParents = [...filteredParents].sort((a, b) => { if (a.status !== "idle" && b.status === "idle") return -1; if (a.status === "idle" && b.status !== "idle") return 1; return b.lastActivityAt - a.lastActivityAt; }); + // Group subagents by parent key + const { subagentsByParent, orphanSubagents } = useMemo(() => { + const byParent = new Map(); + const orphans: MonitorSession[] = []; + const parentKeys = new Set(parentSessions.map((s) => s.key)); + + for (const session of sessions) { + if (!isSubagent(session)) continue; + const matchesFilter = + !filter || + session.agentId.toLowerCase().includes(filter.toLowerCase()) || + "subagent".includes(filter.toLowerCase()); + if (!matchesFilter) continue; + + if (session.spawnedBy && parentKeys.has(session.spawnedBy)) { + const list = byParent.get(session.spawnedBy) ?? []; + list.push(session); + byParent.set(session.spawnedBy, list); + } else { + orphans.push(session); + } + } + + // Sort subagents within each group by activity + for (const [key, list] of byParent) { + list.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + byParent.set(key, list); + } + orphans.sort((a, b) => b.lastActivityAt - a.lastActivityAt); + + return { subagentsByParent: byParent, orphanSubagents: orphans }; + }, [sessions, parentSessions, filter]); + return ( + + {subs.map((sub) => ( + + ))} + + + ); + })()} + + ))} + + {/* Orphan subagents */} + {orphanSubagents.map((sub) => ( + ))} - {sortedSessions.length === 0 && !collapsed && ( + {sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
> no sessions found