refactor(graph): extract popover to features layer — reuse project UI (DRY)
- New: GraphNodePopover.tsx in features/agent-graph/ui/ Uses @renderer/components/ui/badge, button, agentAvatarUrl No code duplication with MemberCard/MemberHoverCard patterns - GraphView: added renderOverlay prop — host app injects its own popover Falls back to built-in GraphOverlay if renderOverlay not provided - TeamGraphTab + TeamGraphOverlay pass renderOverlay with GraphNodePopover Member popover: avatar, status dot, role, context bar, badges, Message/Profile Task popover: displayId, status/review badges, Open button Process popover: label, URL link
This commit is contained in:
parent
2624666f91
commit
54c259b017
4 changed files with 344 additions and 7 deletions
|
|
@ -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<string | null>(null);
|
||||
|
|
@ -335,12 +342,29 @@ export function GraphView({
|
|||
isAlive={data.isAlive}
|
||||
/>
|
||||
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
worldToScreen={camera.worldToScreen}
|
||||
events={events}
|
||||
onDeselect={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
{selectedNode && renderOverlay ? (
|
||||
<div
|
||||
className="absolute z-20 pointer-events-auto"
|
||||
style={{
|
||||
left: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).x + 20}px`,
|
||||
top: `${camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0).y - 20}px`,
|
||||
transform: 'translateY(-50%)',
|
||||
}}
|
||||
>
|
||||
{renderOverlay({
|
||||
node: selectedNode,
|
||||
screenPos: camera.worldToScreen(selectedNode.x ?? 0, selectedNode.y ?? 0),
|
||||
onClose: () => setSelectedNodeId(null),
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<GraphOverlay
|
||||
selectedNode={selectedNode}
|
||||
worldToScreen={camera.worldToScreen}
|
||||
events={events}
|
||||
onDeselect={() => setSelectedNodeId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
272
src/renderer/features/agent-graph/ui/GraphNodePopover.tsx
Normal file
272
src/renderer/features/agent-graph/ui/GraphNodePopover.tsx
Normal file
|
|
@ -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 (
|
||||
<MemberPopoverContent
|
||||
node={node}
|
||||
onClose={onClose}
|
||||
onSendMessage={onSendMessage}
|
||||
onOpenProfile={onOpenMemberProfile}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (node.kind === 'task') {
|
||||
return <TaskPopoverContent node={node} onClose={onClose} onOpenDetail={onOpenTaskDetail} />;
|
||||
}
|
||||
|
||||
// Process
|
||||
return (
|
||||
<div className="min-w-[180px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
<div className="font-mono text-xs font-bold text-[var(--color-text)]">{node.label}</div>
|
||||
{node.processUrl && (
|
||||
<a
|
||||
href={node.processUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="mt-2 flex items-center gap-1 text-xs text-blue-400 hover:underline"
|
||||
>
|
||||
<ExternalLink size={12} /> Open URL
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
{/* Header: avatar + name */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative shrink-0">
|
||||
<img
|
||||
src={avatarSrc}
|
||||
alt={memberName}
|
||||
className="size-10 rounded-full border border-[var(--color-border)]"
|
||||
/>
|
||||
<div
|
||||
className={`absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-[var(--color-surface-raised)] ${statusDotColor}`}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div
|
||||
className="truncate text-sm font-semibold text-[var(--color-text)]"
|
||||
style={{ color: node.color }}
|
||||
>
|
||||
{node.label.split(' · ')[0]}
|
||||
</div>
|
||||
{node.role && (
|
||||
<div className="truncate text-xs text-[var(--color-text-muted)]">{node.role}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status badges */}
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className="px-1.5 py-0 text-[10px]">
|
||||
{statusLabel}
|
||||
</Badge>
|
||||
{node.kind === 'lead' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 px-1.5 py-0 text-[10px] text-blue-400"
|
||||
>
|
||||
Lead
|
||||
</Badge>
|
||||
)}
|
||||
{node.spawnStatus && node.spawnStatus !== 'online' && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 px-1.5 py-0 text-[10px] text-amber-400"
|
||||
>
|
||||
{node.spawnStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Context usage for lead */}
|
||||
{node.kind === 'lead' && node.contextUsage != null && node.contextUsage > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="flex items-center justify-between text-[10px] text-[var(--color-text-muted)]">
|
||||
<span>Context</span>
|
||||
<span>{Math.round(node.contextUsage * 100)}%</span>
|
||||
</div>
|
||||
<div className="mt-0.5 h-1 rounded-full bg-[var(--color-border)]">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${Math.min(100, node.contextUsage * 100)}%`,
|
||||
background:
|
||||
node.contextUsage > 0.9
|
||||
? '#ef4444'
|
||||
: node.contextUsage > 0.8
|
||||
? '#f59e0b'
|
||||
: '#22c55e',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="mt-3 flex gap-1.5">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onSendMessage?.(memberName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<MessageSquare size={12} /> Message
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onOpenProfile?.(memberName);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<User size={12} /> Profile
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="min-w-[200px] max-w-[280px] rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-raised)] p-3 shadow-xl">
|
||||
<div className="font-mono text-sm font-bold text-[var(--color-text)]">
|
||||
{node.displayId ?? node.label}
|
||||
</div>
|
||||
{node.sublabel && (
|
||||
<div className="mt-0.5 truncate text-xs text-[var(--color-text-muted)]">
|
||||
{node.sublabel}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-1">
|
||||
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${statusColor}`}>
|
||||
{node.taskStatus ?? 'pending'}
|
||||
</Badge>
|
||||
{node.reviewState && node.reviewState !== 'none' && (
|
||||
<Badge variant="outline" className={`px-1.5 py-0 text-[10px] ${reviewColor}`}>
|
||||
{node.reviewState}
|
||||
</Badge>
|
||||
)}
|
||||
{node.needsClarification && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-red-500/30 px-1.5 py-0 text-[10px] text-red-400"
|
||||
>
|
||||
needs clarification
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-xs"
|
||||
onClick={() => {
|
||||
onOpenDetail?.(taskId);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} /> Open task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) => (
|
||||
<GraphNodePopover
|
||||
node={node}
|
||||
onClose={closePopover}
|
||||
onSendMessage={(name) => {
|
||||
onSendMessage?.(name);
|
||||
closePopover();
|
||||
}}
|
||||
onOpenTaskDetail={(id) => {
|
||||
onOpenTaskDetail?.(id);
|
||||
closePopover();
|
||||
}}
|
||||
onOpenMemberProfile={(name) => {
|
||||
onOpenMemberProfile?.(name);
|
||||
closePopover();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 }) => (
|
||||
<GraphNodePopover
|
||||
node={node}
|
||||
onClose={onClose}
|
||||
onSendMessage={(name) =>
|
||||
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 && (
|
||||
<Suspense fallback={null}>
|
||||
|
|
|
|||
Loading…
Reference in a new issue