mirror of
https://github.com/crabwise-ai/crabwalk.git
synced 2026-08-14 00:57:52 +00:00
feat(monitor): nest subagent sessions, reorganizing feature (#15)
* feat(monitor): enhance session list with subagent grouping and improved filtering - Introduced SubagentItem component for rendering subagent sessions. - Grouped subagents under their parent sessions based on the spawnedBy relationship. - Updated filtering logic to separate parent sessions from subagents. - Enhanced sorting and display of sessions, including orphan subagents. - Improved UI for session selection and status indication. This update improves the organization and usability of the session list in the monitor component. * fix(monitor): update session display to show recipient instead of agentId - Changed the displayed identifier in the SessionList component from agentId to recipient for improved clarity in session representation. * feat(monitor): add node position pinning and reorganize layout functionality - Introduced functionality to pin node positions upon drag-end, enhancing user experience in the ActionGraph component. - Added a button to reorganize the layout, clearing pinned positions and reapplying the layout for better visual management. - Updated the node state handling to incorporate pinned positions during layout updates, ensuring a smoother interaction with the graph. * fix(monitor): update styling for ExecNode and SessionNode components - Changed the CSS class for text wrapping in ExecNode to improve readability. - Modified the border and box shadow logic in SessionNode to enhance visual feedback based on session status, particularly for 'thinking' states. * feat(monitor): enhance session node representation with thinking state - Added logic to determine which sessions are in a "thinking" state based on the latest action type in the ActionGraph component. - Updated SessionNode to accept a new `thinking` property, modifying the visual feedback (border and box shadow) to reflect this state, improving user experience and clarity in session status. * revert: undo session thinking state and exec/session styling changes * fix(monitor): update SubagentItem emoji representation * feat(monitor): implement collapsible subagent groups in SessionList - Added functionality to collapse and expand subagent groups within the SessionList component. - Introduced a new state to manage collapsed groups and updated the UI to reflect the expanded/collapsed state with a ChevronDown icon. - Enhanced user experience by allowing users to easily navigate through subagent sessions. * style(monitor): update SessionList component styling - Adjusted padding and alignment for improved layout in the SessionList component. - Increased ChevronDown icon size for better visibility. - Enhanced text styling for consistency and readability. * refactor(monitor): optimize animation handling in SessionList component - Updated SubagentItem animations to improve performance and visual feedback. - Replaced AnimatePresence with motion.div for better control over height and opacity transitions during group collapse/expand. - Simplified initial animation states for a smoother user experience.
This commit is contained in:
@@ -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<Set<string>>(new Set())
|
||||
const nodePositionsRef = useRef<Map<string, { x: number; y: number }>>(new Map())
|
||||
const pinnedPositions = useRef<Map<string, { x: number; y: number }>>(new Map())
|
||||
const animationFrameRef = useRef<number>(undefined)
|
||||
const timeoutRef = useRef<NodeJS.Timeout>(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({
|
||||
<Controls
|
||||
className="bg-shell-900! border-shell-700! shadow-lg! [&>button]:bg-shell-800! [&>button]:border-shell-700! [&>button]:text-gray-300! [&>button:hover]:bg-shell-700! [&>button>svg]:fill-gray-300!"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 z-10">
|
||||
<button
|
||||
onClick={handleReorganize}
|
||||
title="Re-organize layout"
|
||||
className="p-1.5 rounded bg-shell-800 border border-shell-700 text-gray-300 hover:bg-shell-700 shadow-lg cursor-pointer"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<MiniMap
|
||||
nodeColor={(node) => {
|
||||
if (node.type === 'crab') return '#ef4444'
|
||||
|
||||
@@ -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<string, string> = {
|
||||
slack: "💼",
|
||||
};
|
||||
|
||||
function SubagentItem({
|
||||
session,
|
||||
selected,
|
||||
collapsed,
|
||||
onSelect,
|
||||
}: {
|
||||
session: MonitorSession;
|
||||
selected: boolean;
|
||||
collapsed: boolean;
|
||||
onSelect: (key: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<motion.button
|
||||
initial={false}
|
||||
animate={{ opacity: 1 }}
|
||||
onClick={() => 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 ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-sm">🤖</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-neon-cyan/60 uppercase tracking-widest mb-1">
|
||||
subagent
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">🤖</span>
|
||||
<span className="font-console text-[11px] text-shell-400 truncate flex-1 group-hover:text-shell-200">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
);
|
||||
}
|
||||
|
||||
export function SessionList({
|
||||
sessions,
|
||||
selectedKey,
|
||||
@@ -50,10 +102,12 @@ export function SessionList({
|
||||
}: SessionListProps) {
|
||||
const [filter, setFilter] = useState("");
|
||||
const [platformFilter, setPlatformFilter] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(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<string, MonitorSession[]>();
|
||||
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 (
|
||||
<motion.div
|
||||
className="flex flex-col h-full bg-shell-900 relative"
|
||||
@@ -119,7 +206,7 @@ export function SessionList({
|
||||
<div className="flex gap-1.5 mt-3 flex-wrap">
|
||||
<button
|
||||
onClick={() => setPlatformFilter(null)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
!platformFilter
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
@@ -131,7 +218,7 @@ export function SessionList({
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setPlatformFilter(p)}
|
||||
className={`px-2.5 py-1 text-[10px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
className={`px-2.5 py-1 text-[11px] font-display uppercase tracking-wide rounded border transition-all ${
|
||||
platformFilter === p
|
||||
? "bg-crab-600 border-crab-500 text-white box-glow-red"
|
||||
: "bg-shell-800 border-shell-700 text-gray-400 hover:border-shell-600 hover:text-gray-300"
|
||||
@@ -149,64 +236,129 @@ export function SessionList({
|
||||
{/* Session list */}
|
||||
<div className="relative flex-1 overflow-y-auto">
|
||||
<AnimatePresence mode="popLayout">
|
||||
{sortedSessions.map((session) => (
|
||||
<motion.button
|
||||
key={session.key}
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
|
||||
selectedKey === session.key
|
||||
? "bg-crab-900/20 border-l-2 border-l-crab-500"
|
||||
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
|
||||
}`}
|
||||
title={
|
||||
collapsed
|
||||
? `${session.recipient} (${session.platform})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
// Collapsed view: just icon and status
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
) : (
|
||||
// Expanded view
|
||||
<>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
{sortedParents.map((session) => (
|
||||
<div key={session.key}>
|
||||
<motion.button
|
||||
layout
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
onClick={() => onSelect(session.key)}
|
||||
className={`w-full text-left p-3 border-b border-shell-800 transition-all duration-150 group ${
|
||||
selectedKey === session.key
|
||||
? "bg-crab-900/20 border-l-2 border-l-crab-500"
|
||||
: "hover:bg-shell-800/50 border-l-2 border-l-transparent"
|
||||
}`}
|
||||
title={
|
||||
collapsed
|
||||
? `${session.recipient} (${session.platform})`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{collapsed ? (
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[10px] text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
|
||||
<Users size={10} />
|
||||
group
|
||||
) : (
|
||||
<>
|
||||
<div className="font-display text-[9px] font-medium text-shell-500 uppercase tracking-widest mb-1">
|
||||
main
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<span className="text-lg">
|
||||
{platformEmoji[session.platform] || "📱"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
<span className="font-display text-xs font-medium text-gray-200 truncate flex-1 uppercase tracking-wide group-hover:text-white">
|
||||
{session.recipient}
|
||||
</span>
|
||||
<StatusIndicator status={session.status} size="sm" />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-console text-[11px] text-shell-500 truncate flex-1">
|
||||
{session.agentId}
|
||||
</span>
|
||||
{session.isGroup && (
|
||||
<span className="flex items-center gap-1 px-1.5 py-0.5 bg-shell-800 border border-shell-700 rounded text-[11px] text-shell-400">
|
||||
<Users size={10} />
|
||||
group
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.button>
|
||||
|
||||
{/* Nested subagents */}
|
||||
{(() => {
|
||||
const subs = subagentsByParent.get(session.key);
|
||||
if (!subs?.length) return null;
|
||||
const isGroupCollapsed = collapsedGroups.has(session.key);
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(session.key)) next.delete(session.key);
|
||||
else next.add(session.key);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className={`w-full border-b border-shell-800/50 transition-all ${
|
||||
collapsed ? "p-2 justify-center" : "px-4 py-1.5 text-left"
|
||||
} flex items-center gap-1.5 text-xs font-display uppercase tracking-widest text-shell-500 hover:text-shell-300 hover:bg-shell-800/30`}
|
||||
>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform ${isGroupCollapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
{!collapsed && (
|
||||
<span>{subs.length} subagent{subs.length > 1 ? "s" : ""}</span>
|
||||
)}
|
||||
</button>
|
||||
<motion.div
|
||||
initial={false}
|
||||
animate={{
|
||||
height: isGroupCollapsed ? 0 : "auto",
|
||||
opacity: isGroupCollapsed ? 0 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.15, ease: "easeInOut" }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{subs.map((sub) => (
|
||||
<SubagentItem
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</motion.div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Orphan subagents */}
|
||||
{orphanSubagents.map((sub) => (
|
||||
<SubagentItem
|
||||
key={sub.key}
|
||||
session={sub}
|
||||
selected={selectedKey === sub.key}
|
||||
collapsed={collapsed}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</AnimatePresence>
|
||||
|
||||
{sortedSessions.length === 0 && !collapsed && (
|
||||
{sortedParents.length === 0 && orphanSubagents.length === 0 && !collapsed && (
|
||||
<div className="p-6 text-center">
|
||||
<div className="font-console text-xs text-shell-500">
|
||||
<span className="text-crab-600">></span> no sessions found
|
||||
|
||||
Reference in New Issue
Block a user