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:
parent
cb17e2158f
commit
2624666f91
3 changed files with 435 additions and 57 deletions
|
|
@ -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]));
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue