fix(graph): no spawn replay on toggle + always bloom + rich popovers

1. Tasks toggle no longer replays spawn animations — allKnownNodeIds
   tracks every node ever seen, spawn effects only for genuinely new nodes
2. Bloom always active (removed hasActivity condition) — no brightness
   flicker when tasks appear/disappear
3. Rich member popover: avatar image, status dot, role, context bar,
   spawn status badges, Message/Profile action buttons
4. Rich task popover: displayId, status badges, review state, Open button
5. Process popover: label, URL link, status
6. Glass-card styling with colored accent bar, outside-click-to-dismiss
This commit is contained in:
iliya 2026-03-28 14:20:59 +02:00
parent cb17e2158f
commit 2624666f91
3 changed files with 435 additions and 57 deletions

View file

@ -167,11 +167,13 @@ export function useGraphSimulation(): UseGraphSimulationResult {
// Track previous node IDs and states for effect spawning
const prevNodeIdsRef = useRef(new Set<string>());
const prevNodeStatesRef = useRef(new Map<string, string>());
// All node IDs ever seen — never shrinks. Prevents spawn effects replaying
// when nodes reappear after being filtered out (e.g. Tasks toggle OFF→ON).
const allKnownNodeIdsRef = useRef(new Set<string>());
// Update data from adapter
const updateData = useCallback((nodes: GraphNode[], edges: GraphEdge[], particles: GraphParticle[]) => {
const state = stateRef.current;
const prevIds = prevNodeIdsRef.current;
const prevStates = prevNodeStatesRef.current;
// Preserve positions from previous frame
@ -193,9 +195,11 @@ export function useGraphSimulation(): UseGraphSimulationResult {
}
// Detect state transitions → spawn visual effects
const allKnown = allKnownNodeIdsRef.current;
for (const node of nodes) {
// New node appeared → spawn effect
if (!prevIds.has(node.id) && node.x != null && node.y != null) {
// New node appeared → spawn effect (only if truly new, never seen before).
// Nodes returning from filter (e.g. Tasks toggle OFF→ON) are already in allKnown.
if (!allKnown.has(node.id) && node.x != null && node.y != null) {
state.effects.push(createSpawnEffect(node.x, node.y, node.color ?? getStateColor(node.state)));
}
@ -206,7 +210,8 @@ export function useGraphSimulation(): UseGraphSimulationResult {
}
}
// Update tracking refs
// Update tracking refs — allKnown only grows, never shrinks
for (const n of nodes) allKnown.add(n.id);
prevNodeIdsRef.current = new Set(nodes.map((n) => n.id));
prevNodeStatesRef.current = new Map(nodes.map((n) => [n.id, n.state]));

View file

@ -219,9 +219,8 @@ export const GraphCanvas = forwardRef<GraphCanvasHandle, GraphCanvasProps>(funct
ctx.restore(); // world space
ctx.restore(); // DPR scale
// 3. Bloom post-processing — skip when scene is fully idle (saves 3 blur passes)
const hasActivity = state.particles.length > 0 || state.effects.length > 0;
if (bloomIntensity > 0 && hasActivity) {
// 3. Bloom post-processing — always active for space aesthetic
if (bloomIntensity > 0) {
bloomRef.current.apply(canvas, ctx);
}

View file

@ -1,12 +1,15 @@
/**
* GraphOverlay HTML popovers positioned over Canvas nodes.
* Uses camera worldToScreen transform for positioning.
*
* Styled to match the host app's MemberHoverCard / MemberCard look:
* avatar + status dot, name, role, status badges, action buttons.
*/
import { useCallback } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type { GraphNode } from '../ports/types';
import type { GraphEventPort } from '../ports/GraphEventPort';
import { getStateColor, getTaskStatusColor } from '../constants/colors';
import { COLORS, getStateColor, getTaskStatusColor } from '../constants/colors';
export interface GraphOverlayProps {
selectedNode: GraphNode | null;
@ -39,6 +42,84 @@ export function GraphOverlay({
);
}
// ─── SVG Icons (inline — package cannot import lucide-react) ────────────────
function IconMessage({ size = 13 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);
}
function IconExternalLink({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
);
}
function IconGlobe({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="2" y1="12" x2="22" y2="12" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</svg>
);
}
function IconClipboard({ size = 12 }: { size?: number }): React.JSX.Element {
return (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="4" rx="1" />
<path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" />
</svg>
);
}
// ─── State helpers ──────────────────────────────────────────────────────────
function getPresenceLabel(state: GraphNode['state']): string {
switch (state) {
case 'active': return 'active';
case 'thinking': return 'thinking';
case 'tool_calling': return 'tool calling';
case 'idle': return 'idle';
case 'waiting': return 'waiting';
case 'complete': return 'done';
case 'error': return 'error';
case 'terminated': return 'offline';
}
}
function getStatusDotClass(state: GraphNode['state']): string {
switch (state) {
case 'active':
case 'thinking':
case 'tool_calling':
return 'animate-pulse';
default:
return '';
}
}
/** Capitalise first letter, replace underscores with spaces */
function formatLabel(s: string): string {
return s.replace(/_/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
}
/** Truncate display name: "team-lead" → "Team Lead", "alice" → "Alice" */
function displayName(name: string): string {
return name
.split(/[-_]/)
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(' ');
}
// ─── Node Popover ───────────────────────────────────────────────────────────
function NodePopover({
@ -50,6 +131,23 @@ function NodePopover({
events?: GraphEventPort;
onClose: () => void;
}): React.JSX.Element {
const popoverRef = useRef<HTMLDivElement>(null);
// Close on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (popoverRef.current && !popoverRef.current.contains(e.target as Node)) {
onClose();
}
};
// Delay to avoid closing immediately from the click that opened it
const timer = setTimeout(() => document.addEventListener('mousedown', handler), 50);
return () => {
clearTimeout(timer);
document.removeEventListener('mousedown', handler);
};
}, [onClose]);
const handleAction = useCallback(
(action: string) => {
const ref = node.domainRef;
@ -62,6 +160,7 @@ function NodePopover({
case 'openDetail':
if (ref.kind === 'task') events?.onOpenTaskDetail?.(ref.taskId, ref.teamName);
else if (ref.kind === 'member') events?.onOpenMemberProfile?.(ref.memberName, ref.teamName);
else if (ref.kind === 'lead') events?.onOpenMemberProfile?.('team-lead', ref.teamName);
break;
case 'openUrl':
if (node.processUrl) window.open(node.processUrl, '_blank');
@ -72,93 +171,368 @@ function NodePopover({
[node, events, onClose],
);
const isMemberLike = node.kind === 'member' || node.kind === 'lead';
const color = node.kind === 'task'
? getTaskStatusColor(node.taskStatus)
: getStateColor(node.state);
: node.color ?? getStateColor(node.state);
const stateColor = getStateColor(node.state);
if (isMemberLike) {
return <MemberPopover ref={popoverRef} node={node} color={color} stateColor={stateColor} onAction={handleAction} />;
}
if (node.kind === 'task') {
return <TaskPopover ref={popoverRef} node={node} color={color} stateColor={stateColor} onAction={handleAction} />;
}
return <ProcessPopover ref={popoverRef} node={node} color={color} onAction={handleAction} />;
}
// ─── Member / Lead Popover ──────────────────────────────────────────────────
import { forwardRef } from 'react';
const MemberPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void }
>(function MemberPopover({ node, color, stateColor, onAction }, ref) {
const presenceText = getPresenceLabel(node.state);
const dotAnim = getStatusDotClass(node.state);
return (
<div
className="rounded-lg p-3 min-w-[180px] max-w-[260px] shadow-xl"
ref={ref}
className="rounded-lg min-w-[220px] max-w-[280px] shadow-xl overflow-hidden"
style={{
background: 'rgba(10, 15, 30, 0.9)',
border: `1px solid ${color}40`,
backdropFilter: 'blur(8px)',
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
{/* Header */}
<div className="flex items-center gap-2 mb-2">
<div
className="w-2.5 h-2.5 rounded-full"
style={{ background: color }}
/>
<span
className="text-xs font-mono font-bold truncate"
style={{ color: '#aaeeff' }}
>
{node.label}
</span>
</div>
{/* Colored top accent */}
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
{/* Info */}
{node.sublabel && (
<div className="text-[10px] mb-2 truncate" style={{ color: '#66ccff90' }}>
{node.sublabel}
<div className="p-3 flex flex-col gap-2.5">
{/* Header: avatar + name + status */}
<div className="flex items-center gap-3">
{node.avatarUrl ? (
<div className="relative shrink-0">
<img
src={node.avatarUrl}
alt={node.label}
className="rounded-full"
style={{
width: 40,
height: 40,
background: 'rgba(100, 200, 255, 0.08)',
border: `2px solid ${color}40`,
}}
loading="lazy"
/>
<span
className={`absolute rounded-full ${dotAnim}`}
style={{
bottom: -1,
right: -1,
width: 12,
height: 12,
background: stateColor,
border: '2px solid rgba(10, 15, 30, 0.9)',
}}
/>
</div>
) : (
<div className="relative shrink-0">
<div
className="rounded-full flex items-center justify-center"
style={{
width: 40,
height: 40,
background: `${color}20`,
border: `2px solid ${color}40`,
color: COLORS.holoBright,
fontSize: 14,
fontWeight: 700,
}}
>
{node.label.charAt(0).toUpperCase()}
</div>
<span
className={`absolute rounded-full ${dotAnim}`}
style={{
bottom: -1,
right: -1,
width: 12,
height: 12,
background: stateColor,
border: '2px solid rgba(10, 15, 30, 0.9)',
}}
/>
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span
className="truncate font-bold"
style={{ color, fontSize: 13 }}
>
{displayName(node.label)}
</span>
</div>
{node.role && (
<div
className="truncate"
style={{ color: COLORS.textDim, fontSize: 11, marginTop: 1 }}
>
{node.role}
</div>
)}
</div>
</div>
)}
{node.role && (
<div className="text-[10px] mb-2" style={{ color: '#66ccff70' }}>
{node.role}
{/* Status badge */}
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={presenceText} color={stateColor} />
{node.kind === 'lead' && (
<StatusBadge label="lead" color={COLORS.dispatch} />
)}
{node.spawnStatus && node.spawnStatus !== 'online' && (
<StatusBadge label={node.spawnStatus} color={
node.spawnStatus === 'error' ? COLORS.error :
node.spawnStatus === 'spawning' ? COLORS.waiting :
COLORS.terminated
} />
)}
</div>
)}
{/* Status badges */}
<div className="flex gap-1 mb-2 flex-wrap">
<StatusBadge label={node.state} color={color} />
{node.reviewState && node.reviewState !== 'none' && (
<StatusBadge label={node.reviewState} color={getTaskStatusColor(node.taskStatus)} />
{/* Context usage bar (lead only) */}
{node.contextUsage != null && node.contextUsage > 0 && (
<div>
<div className="flex justify-between" style={{ fontSize: 10, color: COLORS.textDim, marginBottom: 2 }}>
<span>Context</span>
<span>{Math.round(node.contextUsage * 100)}%</span>
</div>
<div
className="rounded-full overflow-hidden"
style={{ height: 3, background: 'rgba(100, 200, 255, 0.1)' }}
>
<div
className="rounded-full h-full transition-all"
style={{
width: `${Math.round(node.contextUsage * 100)}%`,
background: node.contextUsage > 0.8 ? COLORS.error : color,
}}
/>
</div>
</div>
)}
{/* Sublabel (current activity) */}
{node.sublabel && (
<div
className="rounded px-2 py-1.5 truncate"
style={{
fontSize: 10,
color: COLORS.textDim,
background: 'rgba(100, 200, 255, 0.05)',
border: `1px solid ${COLORS.glassBorder}`,
}}
>
{node.sublabel}
</div>
)}
{/* Action buttons */}
<div className="flex gap-1.5">
<ActionButton icon={<IconMessage />} label="Message" onClick={() => onAction('sendMessage')} color={color} />
<ActionButton icon={<IconExternalLink />} label="Profile" onClick={() => onAction('openDetail')} color={color} />
</div>
</div>
</div>
);
});
{/* Actions */}
<div className="flex gap-1 mt-2">
{(node.kind === 'member' || node.kind === 'lead') && (
<ActionButton label="Message" onClick={() => handleAction('sendMessage')} />
// ─── Task Popover ───────────────────────────────────────────────────────────
const TaskPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; stateColor: string; onAction: (a: string) => void }
>(function TaskPopover({ node, color, stateColor, onAction }, ref) {
const taskStatusLabel = node.taskStatus ? formatLabel(node.taskStatus) : 'pending';
return (
<div
ref={ref}
className="rounded-lg min-w-[200px] max-w-[280px] shadow-xl overflow-hidden"
style={{
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
{/* Colored top accent */}
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
<div className="p-3 flex flex-col gap-2">
{/* Header: display ID + label */}
<div className="flex items-center gap-2">
{node.displayId && (
<span
className="shrink-0 font-mono font-bold"
style={{ color, fontSize: 12 }}
>
{node.displayId}
</span>
)}
<span
className="truncate font-bold"
style={{ color: COLORS.holoBright, fontSize: 12 }}
>
{node.label}
</span>
</div>
{/* Subject / description */}
{node.sublabel && (
<div
className="truncate"
style={{ color: COLORS.textDim, fontSize: 11 }}
>
{node.sublabel}
</div>
)}
{(node.kind === 'task' || node.kind === 'member') && (
<ActionButton label="Open" onClick={() => handleAction('openDetail')} />
{/* Status badges */}
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={taskStatusLabel} color={color} />
{node.state !== 'idle' && (
<StatusBadge label={getPresenceLabel(node.state)} color={stateColor} />
)}
{node.reviewState && node.reviewState !== 'none' && (
<StatusBadge
label={node.reviewState === 'needsFix' ? 'needs fix' : node.reviewState}
color={
node.reviewState === 'approved' ? COLORS.complete :
node.reviewState === 'needsFix' ? COLORS.error :
COLORS.waiting
}
/>
)}
{node.needsClarification && (
<StatusBadge label={`needs ${node.needsClarification}`} color={COLORS.waiting} />
)}
</div>
{/* Action */}
<div className="flex gap-1.5 mt-0.5">
<ActionButton icon={<IconClipboard />} label="Open task" onClick={() => onAction('openDetail')} color={color} />
</div>
</div>
</div>
);
});
// ─── Process Popover ────────────────────────────────────────────────────────
const ProcessPopover = forwardRef<
HTMLDivElement,
{ node: GraphNode; color: string; onAction: (a: string) => void }
>(function ProcessPopover({ node, color, onAction }, ref) {
return (
<div
ref={ref}
className="rounded-lg min-w-[180px] max-w-[260px] shadow-xl overflow-hidden"
style={{
background: COLORS.glassBg,
border: `1px solid ${color}30`,
backdropFilter: 'blur(12px)',
}}
>
<div style={{ height: 3, background: `linear-gradient(to right, ${color}, transparent)` }} />
<div className="p-3 flex flex-col gap-2">
<div className="flex items-center gap-2">
<div
className="rounded-full"
style={{ width: 8, height: 8, background: color }}
/>
<span
className="truncate font-bold"
style={{ color: COLORS.holoBright, fontSize: 12 }}
>
{node.label}
</span>
</div>
{node.sublabel && (
<div className="truncate" style={{ color: COLORS.textDim, fontSize: 11 }}>
{node.sublabel}
</div>
)}
{node.kind === 'process' && node.processUrl && (
<ActionButton label="Open URL" onClick={() => handleAction('openUrl')} />
<div className="flex gap-1.5 flex-wrap">
<StatusBadge label={getPresenceLabel(node.state)} color={getStateColor(node.state)} />
</div>
{node.processUrl && (
<div className="flex gap-1.5 mt-0.5">
<ActionButton icon={<IconGlobe />} label="Open URL" onClick={() => onAction('openUrl')} color={color} />
</div>
)}
</div>
</div>
);
}
});
// ─── UI Primitives ──────────────────────────────────────────────────────────
function StatusBadge({ label, color }: { label: string; color: string }): React.JSX.Element {
return (
<span
className="text-[9px] px-1.5 py-0.5 rounded font-mono"
style={{ background: `${color}20`, color, border: `1px solid ${color}30` }}
className="px-2 py-0.5 rounded-full font-medium"
style={{
fontSize: 10,
background: `${color}18`,
color,
border: `1px solid ${color}25`,
letterSpacing: '0.01em',
}}
>
{label}
</span>
);
}
function ActionButton({ label, onClick }: { label: string; onClick: () => void }): React.JSX.Element {
function ActionButton({
icon,
label,
onClick,
color,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
color: string;
}): React.JSX.Element {
return (
<button
onClick={onClick}
className="text-[10px] px-2 py-1 rounded font-mono cursor-pointer transition-colors"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md cursor-pointer transition-all"
style={{
background: 'rgba(100, 200, 255, 0.08)',
border: '1px solid rgba(100, 200, 255, 0.15)',
color: '#aaeeff',
fontSize: 11,
background: `${color}10`,
border: `1px solid ${color}20`,
color: COLORS.holoBright,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = `${color}25`;
e.currentTarget.style.borderColor = `${color}40`;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = `${color}10`;
e.currentTarget.style.borderColor = `${color}20`;
}}
>
{icon}
{label}
</button>
);