diff --git a/packages/agent-graph/src/ui/GraphView.tsx b/packages/agent-graph/src/ui/GraphView.tsx index 4dec015f..24c0f031 100644 --- a/packages/agent-graph/src/ui/GraphView.tsx +++ b/packages/agent-graph/src/ui/GraphView.tsx @@ -32,6 +32,12 @@ export interface GraphViewProps { onRequestClose?: () => void; onRequestPinAsTab?: () => void; onRequestFullscreen?: () => void; + /** Custom overlay renderer — replaces built-in GraphOverlay. Allows host app to reuse its own components. */ + renderOverlay?: (props: { + node: GraphNode; + screenPos: { x: number; y: number }; + onClose: () => void; + }) => React.ReactNode; } export function GraphView({ @@ -42,6 +48,7 @@ export function GraphView({ onRequestClose, onRequestPinAsTab, onRequestFullscreen, + renderOverlay, }: GraphViewProps): React.JSX.Element { // ─── React state (user-facing only) ───────────────────────────────────── const [selectedNodeId, setSelectedNodeId] = useState(null); @@ -335,12 +342,29 @@ export function GraphView({ isAlive={data.isAlive} /> - setSelectedNodeId(null)} - /> + {selectedNode && renderOverlay ? ( +
+ {renderOverlay({ + node: selectedNode, + screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0), + onClose: () => setSelectedNodeId(null), + })} +
+ ) : ( + setSelectedNodeId(null)} + /> + )} ); } diff --git a/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx new file mode 100644 index 00000000..67996ce9 --- /dev/null +++ b/src/renderer/features/agent-graph/ui/GraphNodePopover.tsx @@ -0,0 +1,272 @@ +/** + * GraphNodePopover — renders popover for graph nodes using project UI components. + * Lives in features/ (not in package) so it CAN import from @renderer/. + * Reuses agentAvatarUrl, status helpers, and UI primitives from the project. + */ + +import { Badge } from '@renderer/components/ui/badge'; +import { Button } from '@renderer/components/ui/button'; +import { agentAvatarUrl } from '@renderer/utils/memberHelpers'; +import { MessageSquare, ExternalLink, User } from 'lucide-react'; + +import type { GraphNode } from '@claude-teams/agent-graph'; + +interface GraphNodePopoverProps { + node: GraphNode; + onClose: () => void; + onSendMessage?: (memberName: string) => void; + onOpenTaskDetail?: (taskId: string) => void; + onOpenMemberProfile?: (memberName: string) => void; +} + +export function GraphNodePopover({ + node, + onClose, + onSendMessage, + onOpenTaskDetail, + onOpenMemberProfile, +}: GraphNodePopoverProps): React.JSX.Element { + if (node.kind === 'member' || node.kind === 'lead') { + return ( + + ); + } + + if (node.kind === 'task') { + return ; + } + + // Process + return ( +
+
{node.label}
+ {node.processUrl && ( + + Open URL + + )} +
+ ); +} + +// ─── Member Popover ───────────────────────────────────────────────────────── + +function MemberPopoverContent({ + node, + onClose, + onSendMessage, + onOpenProfile, +}: { + node: GraphNode; + onClose: () => void; + onSendMessage?: (name: string) => void; + onOpenProfile?: (name: string) => void; +}): React.JSX.Element { + const memberName = node.domainRef.kind === 'member' ? node.domainRef.memberName : 'team-lead'; + const avatarSrc = node.avatarUrl ?? agentAvatarUrl(memberName, 64); + const statusLabel = + node.state === 'active' + ? 'Active' + : node.state === 'idle' + ? 'Idle' + : node.state === 'terminated' + ? 'Offline' + : node.state; + + const statusDotColor = + node.state === 'active' || node.state === 'thinking' || node.state === 'tool_calling' + ? 'bg-emerald-400' + : node.state === 'idle' + ? 'bg-zinc-400' + : node.state === 'error' + ? 'bg-red-400' + : 'bg-zinc-600'; + + return ( +
+ {/* Header: avatar + name */} +
+
+ {memberName} +
+
+
+
+ {node.label.split(' · ')[0]} +
+ {node.role && ( +
{node.role}
+ )} +
+
+ + {/* Status badges */} +
+ + {statusLabel} + + {node.kind === 'lead' && ( + + Lead + + )} + {node.spawnStatus && node.spawnStatus !== 'online' && ( + + {node.spawnStatus} + + )} +
+ + {/* Context usage for lead */} + {node.kind === 'lead' && node.contextUsage != null && node.contextUsage > 0 && ( +
+
+ Context + {Math.round(node.contextUsage * 100)}% +
+
+
0.9 + ? '#ef4444' + : node.contextUsage > 0.8 + ? '#f59e0b' + : '#22c55e', + }} + /> +
+
+ )} + + {/* Actions */} +
+ + +
+
+ ); +} + +// ─── Task Popover ─────────────────────────────────────────────────────────── + +function TaskPopoverContent({ + node, + onClose, + onOpenDetail, +}: { + node: GraphNode; + onClose: () => void; + onOpenDetail?: (taskId: string) => void; +}): React.JSX.Element { + const taskId = node.domainRef.kind === 'task' ? node.domainRef.taskId : ''; + + const statusColor = + node.taskStatus === 'in_progress' + ? 'text-blue-400 border-blue-500/30' + : node.taskStatus === 'completed' + ? 'text-emerald-400 border-emerald-500/30' + : 'text-zinc-400 border-zinc-500/30'; + + const reviewColor = + node.reviewState === 'review' + ? 'text-amber-400 border-amber-500/30' + : node.reviewState === 'needsFix' + ? 'text-red-400 border-red-500/30' + : node.reviewState === 'approved' + ? 'text-emerald-400 border-emerald-500/30' + : ''; + + return ( +
+
+ {node.displayId ?? node.label} +
+ {node.sublabel && ( +
+ {node.sublabel} +
+ )} + +
+ + {node.taskStatus ?? 'pending'} + + {node.reviewState && node.reviewState !== 'none' && ( + + {node.reviewState} + + )} + {node.needsClarification && ( + + needs clarification + + )} +
+ +
+ +
+
+ ); +} diff --git a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx index 9a7afd7d..ac42d858 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphOverlay.tsx @@ -8,6 +8,7 @@ import { useCallback } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphNodePopover } from './GraphNodePopover'; import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; @@ -60,6 +61,24 @@ export const TeamGraphOverlay = ({ onRequestClose={onClose} onRequestPinAsTab={onPinAsTab} className="flex-1" + renderOverlay={({ node, onClose: closePopover }) => ( + { + onSendMessage?.(name); + closePopover(); + }} + onOpenTaskDetail={(id) => { + onOpenTaskDetail?.(id); + closePopover(); + }} + onOpenMemberProfile={(name) => { + onOpenMemberProfile?.(name); + closePopover(); + }} + /> + )} />
); diff --git a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx index 041bac55..ba1a0842 100644 --- a/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx +++ b/src/renderer/features/agent-graph/ui/TeamGraphTab.tsx @@ -8,8 +8,9 @@ import { useCallback, useState, lazy, Suspense } from 'react'; import { GraphView } from '@claude-teams/agent-graph'; import { useTeamGraphAdapter } from '../adapters/useTeamGraphAdapter'; +import { GraphNodePopover } from './GraphNodePopover'; -import type { GraphDomainRef, GraphEventPort } from '@claude-teams/agent-graph'; +import type { GraphDomainRef, GraphEventPort, GraphNode } from '@claude-teams/agent-graph'; const TeamGraphOverlay = lazy(() => import('./TeamGraphOverlay').then((m) => ({ default: m.TeamGraphOverlay })) @@ -72,6 +73,27 @@ export const TeamGraphTab = ({ teamName }: TeamGraphTabProps): React.JSX.Element events={events} className="size-full" onRequestFullscreen={() => setFullscreen(true)} + renderOverlay={({ node, onClose }) => ( + + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) + ) + } + onOpenTaskDetail={(id) => + window.dispatchEvent( + new CustomEvent('graph:open-task', { detail: { teamName, taskId: id } }) + ) + } + onOpenMemberProfile={(name) => + window.dispatchEvent( + new CustomEvent('graph:send-message', { detail: { teamName, memberName: name } }) + ) + } + /> + )} /> {fullscreen && (