perf(ui): smooth large lists and graphs

This commit is contained in:
DhanushSantosh
2026-01-19 19:38:56 +05:30
parent f987fc1f10
commit 9bb52f1ded
30 changed files with 1116 additions and 312 deletions

View File

@@ -4,6 +4,7 @@ import type { EdgeProps } from '@xyflow/react';
import { cn } from '@/lib/utils';
import { Feature } from '@/store/app-store';
import { Trash2 } from 'lucide-react';
import { GRAPH_RENDER_MODE_COMPACT, type GraphRenderMode } from '../constants';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
@@ -11,6 +12,7 @@ export interface DependencyEdgeData {
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
@@ -61,6 +63,7 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
const isHighlighted = edgeData?.isHighlighted ?? false;
const isDimmed = edgeData?.isDimmed ?? false;
const isCompact = edgeData?.renderMode === GRAPH_RENDER_MODE_COMPACT;
const edgeColor = isHighlighted
? 'var(--brand-500)'
@@ -86,6 +89,51 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
}
};
if (isCompact) {
return (
<>
<BaseEdge
id={id}
path={edgePath}
className={cn('transition-opacity duration-200', isDimmed && 'graph-edge-dimmed')}
style={{
strokeWidth: selected ? 2 : 1.5,
stroke: selected ? 'var(--status-error)' : edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
opacity: isDimmed ? 0.2 : 1,
}}
/>
{selected && edgeData?.onDeleteDependency && (
<EdgeLabelRenderer>
<div
style={{
position: 'absolute',
transform: `translate(-50%, -50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: 'auto',
zIndex: 1000,
}}
>
<button
onClick={handleDelete}
className={cn(
'flex items-center justify-center',
'w-6 h-6 rounded-full',
'bg-[var(--status-error)] hover:bg-[var(--status-error)]/80',
'text-white shadow-lg',
'transition-all duration-150',
'hover:scale-110'
)}
title="Delete dependency"
>
<Trash2 className="w-3 h-3" />
</button>
</div>
</EdgeLabelRenderer>
)}
</>
);
}
return (
<>
{/* Invisible wider path for hover detection */}

View File

@@ -18,6 +18,7 @@ import {
Trash2,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { GRAPH_RENDER_MODE_COMPACT } from '../constants';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
@@ -109,9 +110,11 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Background/theme settings with defaults
const cardOpacity = data.cardOpacity ?? 100;
const glassmorphism = data.cardGlassmorphism ?? true;
const shouldUseGlassmorphism = data.cardGlassmorphism ?? true;
const cardBorderEnabled = data.cardBorderEnabled ?? true;
const cardBorderOpacity = data.cardBorderOpacity ?? 100;
const isCompact = data.renderMode === GRAPH_RENDER_MODE_COMPACT;
const glassmorphism = shouldUseGlassmorphism && !isCompact;
// Get the border color based on status and error state
const borderColor = data.error
@@ -129,6 +132,99 @@ export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps
// Get computed border style
const borderStyle = getCardBorderStyle(cardBorderEnabled, cardBorderOpacity, borderColor);
if (isCompact) {
return (
<>
<Handle
id="target"
type="target"
position={Position.Left}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
isDimmed && 'opacity-30'
)}
/>
<div
className={cn(
'min-w-[200px] max-w-[240px] rounded-lg shadow-sm relative',
'transition-all duration-200',
selected && 'ring-2 ring-brand-500 ring-offset-1 ring-offset-background',
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
style={borderStyle}
>
<div
className="absolute inset-0 rounded-lg bg-card"
style={{ opacity: cardOpacity / 100 }}
/>
<div className={cn('relative flex items-center gap-2 px-2.5 py-2', config.bgClass)}>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className={cn('text-[11px] font-medium', config.colorClass)}>{config.label}</span>
{priorityConf && (
<span
className={cn(
'ml-auto text-[9px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}
>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
</div>
<div className="relative px-2.5 py-2">
<p
className={cn(
'text-xs text-foreground line-clamp-2',
data.title ? 'font-medium' : 'font-semibold'
)}
>
{data.title || data.description}
</p>
{data.title && data.description && (
<p className="text-[11px] text-muted-foreground line-clamp-1 mt-1">
{data.description}
</p>
)}
{data.isRunning && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-muted-foreground">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-in-progress)]" />
Running
</div>
)}
{isStopped && (
<div className="mt-2 flex items-center gap-2 text-[10px] text-[var(--status-warning)]">
<span className="inline-flex w-1.5 h-1.5 rounded-full bg-[var(--status-warning)]" />
Paused
</div>
)}
</div>
</div>
<Handle
id="source"
type="source"
position={Position.Right}
isConnectable={true}
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: '',
isDimmed && 'opacity-30'
)}
/>
</>
);
}
return (
<>
{/* Target handle (left side - receives dependencies) */}

View File

@@ -0,0 +1,7 @@
export const GRAPH_RENDER_MODE_FULL = 'full';
export const GRAPH_RENDER_MODE_COMPACT = 'compact';
export type GraphRenderMode = typeof GRAPH_RENDER_MODE_FULL | typeof GRAPH_RENDER_MODE_COMPACT;
export const GRAPH_LARGE_NODE_COUNT = 150;
export const GRAPH_LARGE_EDGE_COUNT = 300;

View File

@@ -1,4 +1,4 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
import {
ReactFlow,
Background,
@@ -39,6 +39,12 @@ import { useDebounceValue } from 'usehooks-ts';
import { SearchX, Plus, Wand2, ClipboardCheck } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { PlanSettingsPopover } from '../board-view/dialogs/plan-settings-popover';
import {
GRAPH_LARGE_EDGE_COUNT,
GRAPH_LARGE_NODE_COUNT,
GRAPH_RENDER_MODE_COMPACT,
GRAPH_RENDER_MODE_FULL,
} from './constants';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -198,6 +204,17 @@ function GraphCanvasInner({
// Calculate filter results
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
const estimatedEdgeCount = useMemo(() => {
return features.reduce((total, feature) => {
const deps = feature.dependencies as string[] | undefined;
return total + (deps?.length ?? 0);
}, 0);
}, [features]);
const isLargeGraph =
features.length >= GRAPH_LARGE_NODE_COUNT || estimatedEdgeCount >= GRAPH_LARGE_EDGE_COUNT;
const renderMode = isLargeGraph ? GRAPH_RENDER_MODE_COMPACT : GRAPH_RENDER_MODE_FULL;
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
@@ -205,6 +222,8 @@ function GraphCanvasInner({
filterResult,
actionCallbacks: nodeActionCallbacks,
backgroundSettings,
renderMode,
enableEdgeAnimations: !isLargeGraph,
});
// Apply layout
@@ -457,6 +476,8 @@ function GraphCanvasInner({
}
}, []);
const shouldRenderVisibleOnly = isLargeGraph;
return (
<div className={cn('w-full h-full', className)} style={backgroundStyle}>
<ReactFlow
@@ -478,6 +499,7 @@ function GraphCanvasInner({
maxZoom={2}
selectionMode={SelectionMode.Partial}
connectionMode={ConnectionMode.Loose}
onlyRenderVisibleElements={shouldRenderVisibleOnly}
proOptions={{ hideAttribution: true }}
className="graph-canvas"
>

View File

@@ -51,7 +51,7 @@ export function GraphView({
planUseSelectedWorktreeBranch,
onPlanUseSelectedWorktreeBranchChange,
}: GraphViewProps) {
const { currentProject } = useAppStore();
const currentProject = useAppStore((state) => state.currentProject);
// Use the same background hook as the board view
const { backgroundImageStyle, backgroundSettings } = useBoardBackground({ currentProject });

View File

@@ -54,16 +54,40 @@ function getAncestors(
/**
* Traverses down to find all descendants (features that depend on this one)
*/
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
function getDescendants(
featureId: string,
dependentsMap: Map<string, string[]>,
visited: Set<string>
): void {
if (visited.has(featureId)) return;
visited.add(featureId);
const dependents = dependentsMap.get(featureId);
if (!dependents || dependents.length === 0) return;
for (const dependentId of dependents) {
getDescendants(dependentId, dependentsMap, visited);
}
}
function buildDependentsMap(features: Feature[]): Map<string, string[]> {
const dependentsMap = new Map<string, string[]>();
for (const feature of features) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.includes(featureId)) {
getDescendants(feature.id, features, visited);
if (!deps || deps.length === 0) continue;
for (const depId of deps) {
const existing = dependentsMap.get(depId);
if (existing) {
existing.push(feature.id);
} else {
dependentsMap.set(depId, [feature.id]);
}
}
}
return dependentsMap;
}
/**
@@ -91,9 +115,9 @@ function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[
* Gets the effective status of a feature (accounting for running state)
* Treats completed (archived) as verified
*/
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
function getEffectiveStatus(feature: Feature, runningTaskIds: Set<string>): StatusFilterValue {
if (feature.status === 'in_progress') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
return runningTaskIds.has(feature.id) ? 'running' : 'paused';
}
// Treat completed (archived) as verified
if (feature.status === 'completed') {
@@ -119,6 +143,7 @@ export function useGraphFilter(
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
const runningTaskIds = new Set(runningAutoTasks);
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasStatusFilter = selectedStatuses.length > 0;
@@ -139,6 +164,7 @@ export function useGraphFilter(
// Find directly matched nodes
const matchedNodeIds = new Set<string>();
const featureMap = new Map(features.map((f) => [f.id, f]));
const dependentsMap = buildDependentsMap(features);
for (const feature of features) {
let matchesSearch = true;
@@ -159,7 +185,7 @@ export function useGraphFilter(
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
const effectiveStatus = getEffectiveStatus(feature, runningTaskIds);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
@@ -190,7 +216,7 @@ export function useGraphFilter(
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
getDescendants(id, features, highlightedNodeIds);
getDescendants(id, dependentsMap, highlightedNodeIds);
}
// Get edges in the highlighted path

View File

@@ -1,7 +1,8 @@
import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { createFeatureMap, getBlockingDependenciesFromMap } from '@automaker/dependency-resolver';
import { GRAPH_RENDER_MODE_FULL, type GraphRenderMode } from '../constants';
import { GraphFilterResult } from './use-graph-filter';
export interface TaskNodeData extends Feature {
@@ -31,6 +32,7 @@ export interface TaskNodeData extends Feature {
onResumeTask?: () => void;
onSpawnTask?: () => void;
onDeleteTask?: () => void;
renderMode?: GraphRenderMode;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
@@ -40,6 +42,7 @@ export type DependencyEdge = Edge<{
isHighlighted?: boolean;
isDimmed?: boolean;
onDeleteDependency?: (sourceId: string, targetId: string) => void;
renderMode?: GraphRenderMode;
}>;
export interface NodeActionCallbacks {
@@ -66,6 +69,8 @@ interface UseGraphNodesProps {
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
backgroundSettings?: BackgroundSettings;
renderMode?: GraphRenderMode;
enableEdgeAnimations?: boolean;
}
/**
@@ -78,14 +83,14 @@ export function useGraphNodes({
filterResult,
actionCallbacks,
backgroundSettings,
renderMode = GRAPH_RENDER_MODE_FULL,
enableEdgeAnimations = true,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
const featureMap = new Map<string, Feature>();
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
const featureMap = createFeatureMap(features);
const runningTaskIds = new Set(runningAutoTasks);
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
@@ -95,8 +100,8 @@ export function useGraphNodes({
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
const isRunning = runningTaskIds.has(feature.id);
const blockingDeps = getBlockingDependenciesFromMap(feature, featureMap);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
@@ -121,6 +126,7 @@ export function useGraphNodes({
cardGlassmorphism: backgroundSettings?.cardGlassmorphism,
cardBorderEnabled: backgroundSettings?.cardBorderEnabled,
cardBorderOpacity: backgroundSettings?.cardBorderOpacity,
renderMode,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
@@ -166,13 +172,14 @@ export function useGraphNodes({
source: depId,
target: feature.id,
type: 'dependency',
animated: isRunning || runningAutoTasks.includes(depId),
animated: enableEdgeAnimations && (isRunning || runningTaskIds.has(depId)),
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
onDeleteDependency: actionCallbacks?.onDeleteDependency,
renderMode,
},
};
edgeList.push(edge);