mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-02 08:33:36 +00:00
perf(ui): smooth large lists and graphs
This commit is contained in:
@@ -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 */}
|
||||
|
||||
@@ -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) */}
|
||||
|
||||
7
apps/ui/src/components/views/graph-view/constants.ts
Normal file
7
apps/ui/src/components/views/graph-view/constants.ts
Normal 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;
|
||||
@@ -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"
|
||||
>
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user