branch filtering

This commit is contained in:
James
2025-12-22 18:33:49 -05:00
parent ffcdbf7d75
commit 12a796bcbb
11 changed files with 591 additions and 44 deletions

View File

@@ -1036,6 +1036,8 @@ export function BoardView() {
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
/>

View File

@@ -7,6 +7,8 @@ import { Feature } from '@/store/app-store';
export interface DependencyEdgeData {
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
isHighlighted?: boolean;
isDimmed?: boolean;
}
const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => {
@@ -52,11 +54,17 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
curvature: 0.25,
});
const edgeColor = edgeData
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
: 'var(--border)';
const isHighlighted = edgeData?.isHighlighted ?? false;
const isDimmed = edgeData?.isDimmed ?? false;
const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
const edgeColor = isHighlighted
? 'var(--brand-500)'
: edgeData
? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus)
: 'var(--border)';
const isCompleted =
edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified';
const isInProgress = edgeData?.targetStatus === 'in_progress';
return (
@@ -66,8 +74,9 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
id={`${id}-bg`}
path={edgePath}
style={{
strokeWidth: 4,
strokeWidth: isHighlighted ? 6 : 4,
stroke: 'var(--background)',
opacity: isDimmed ? 0.3 : 1,
}}
/>
@@ -78,13 +87,20 @@ export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) {
className={cn(
'transition-all duration-300',
animated && 'animated-edge',
isInProgress && 'edge-flowing'
isInProgress && 'edge-flowing',
isHighlighted && 'graph-edge-highlighted',
isDimmed && 'graph-edge-dimmed'
)}
style={{
strokeWidth: selected ? 3 : 2,
strokeWidth: isHighlighted ? 4 : selected ? 3 : isDimmed ? 1 : 2,
stroke: edgeColor,
strokeDasharray: isCompleted ? 'none' : '5 5',
filter: selected ? 'drop-shadow(0 0 3px var(--brand-500))' : 'none',
filter: isHighlighted
? 'drop-shadow(0 0 6px var(--brand-500))'
: selected
? 'drop-shadow(0 0 3px var(--brand-500))'
: 'none',
opacity: isDimmed ? 0.2 : 1,
}}
/>

View File

@@ -0,0 +1,198 @@
import { Panel } from '@xyflow/react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Switch } from '@/components/ui/switch';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Filter, X, Eye, EyeOff, ChevronDown } from 'lucide-react';
import { cn } from '@/lib/utils';
import { GraphFilterState } from '../hooks/use-graph-filter';
interface GraphFilterControlsProps {
filterState: GraphFilterState;
availableCategories: string[];
hasActiveFilter: boolean;
onSearchQueryChange: (query: string) => void;
onCategoriesChange: (categories: string[]) => void;
onNegativeFilterChange: (isNegative: boolean) => void;
onClearFilters: () => void;
}
export function GraphFilterControls({
filterState,
availableCategories,
hasActiveFilter,
onSearchQueryChange,
onCategoriesChange,
onNegativeFilterChange,
onClearFilters,
}: GraphFilterControlsProps) {
const { selectedCategories, isNegativeFilter } = filterState;
const handleCategoryToggle = (category: string) => {
if (selectedCategories.includes(category)) {
onCategoriesChange(selectedCategories.filter((c) => c !== category));
} else {
onCategoriesChange([...selectedCategories, category]);
}
};
const handleSelectAllCategories = () => {
if (selectedCategories.length === availableCategories.length) {
onCategoriesChange([]);
} else {
onCategoriesChange([...availableCategories]);
}
};
const categoryButtonLabel =
selectedCategories.length === 0
? 'All Categories'
: selectedCategories.length === 1
? selectedCategories[0]
: `${selectedCategories.length} Categories`;
return (
<Panel position="top-left" className="flex items-center gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex items-center gap-2 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
{/* Category Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedCategories.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<Filter className="w-4 h-4" />
<span className="text-xs max-w-[100px] truncate">{categoryButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Category</TooltipContent>
</Tooltip>
<PopoverContent align="start" className="w-56 p-2">
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground px-2 py-1">
Categories
</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllCategories}
>
<Checkbox
checked={
selectedCategories.length === availableCategories.length &&
availableCategories.length > 0
}
onCheckedChange={handleSelectAllCategories}
/>
<span className="text-sm font-medium">
{selectedCategories.length === availableCategories.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Category list */}
<div className="max-h-48 overflow-y-auto space-y-0.5">
{availableCategories.length === 0 ? (
<div className="text-xs text-muted-foreground px-2 py-2">
No categories available
</div>
) : (
availableCategories.map((category) => (
<div
key={category}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleCategoryToggle(category)}
>
<Checkbox
checked={selectedCategories.includes(category)}
onCheckedChange={() => handleCategoryToggle(category)}
/>
<span className="text-sm truncate">{category}</span>
</div>
))
)}
</div>
</div>
</PopoverContent>
</Popover>
{/* Divider */}
<div className="h-6 w-px bg-border" />
{/* Positive/Negative Filter Toggle */}
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2">
<button
onClick={() => onNegativeFilterChange(!isNegativeFilter)}
className={cn(
'flex items-center gap-1.5 px-2 py-1 rounded text-xs transition-colors',
isNegativeFilter
? 'bg-orange-500/20 text-orange-500'
: 'hover:bg-accent text-muted-foreground hover:text-foreground'
)}
>
{isNegativeFilter ? (
<>
<EyeOff className="w-3.5 h-3.5" />
<span>Hide</span>
</>
) : (
<>
<Eye className="w-3.5 h-3.5" />
<span>Show</span>
</>
)}
</button>
<Switch
checked={isNegativeFilter}
onCheckedChange={onNegativeFilterChange}
className="h-5 w-9 data-[state=checked]:bg-orange-500"
/>
</div>
</TooltipTrigger>
<TooltipContent>
{isNegativeFilter
? 'Negative filter: Highlighting non-matching nodes'
: 'Positive filter: Highlighting matching nodes'}
</TooltipContent>
</Tooltip>
{/* Clear Filters Button - only show when filters are active */}
{hasActiveFilter && (
<>
<div className="h-6 w-px bg-border" />
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive"
onClick={onClearFilters}
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear All Filters</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -2,3 +2,4 @@ export { TaskNode } from './task-node';
export { DependencyEdge } from './dependency-edge';
export { GraphControls } from './graph-controls';
export { GraphLegend } from './graph-legend';
export { GraphFilterControls } from './graph-filter-controls';

View File

@@ -72,14 +72,16 @@ const priorityConfig = {
3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' },
};
export const TaskNode = memo(function TaskNode({
data,
selected,
}: TaskNodeProps) {
export const TaskNode = memo(function TaskNode({ data, selected }: TaskNodeProps) {
const config = statusConfig[data.status] || statusConfig.backlog;
const StatusIcon = config.icon;
const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null;
// Filter highlight states
const isMatched = data.isMatched ?? false;
const isHighlighted = data.isHighlighted ?? false;
const isDimmed = data.isDimmed ?? false;
return (
<>
{/* Target handle (left side - receives dependencies) */}
@@ -89,39 +91,46 @@ export const TaskNode = memo(function TaskNode({
className={cn(
'w-3 h-3 !bg-border border-2 border-background',
'transition-colors duration-200',
'hover:!bg-brand-500'
'hover:!bg-brand-500',
isDimmed && 'opacity-30'
)}
/>
<div
className={cn(
'min-w-[240px] max-w-[280px] rounded-xl border-2 bg-card shadow-md',
'transition-all duration-200',
'transition-all duration-300',
config.borderClass,
selected && 'ring-2 ring-brand-500 ring-offset-2 ring-offset-background',
data.isRunning && 'animate-pulse-subtle',
data.error && 'border-[var(--status-error)]'
data.error && 'border-[var(--status-error)]',
// Filter highlight states
isMatched && 'graph-node-matched',
isHighlighted && !isMatched && 'graph-node-highlighted',
isDimmed && 'graph-node-dimmed'
)}
>
{/* Header with status and actions */}
<div className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass
)}>
<div
className={cn(
'flex items-center justify-between px-3 py-2 rounded-t-[10px]',
config.bgClass
)}
>
<div className="flex items-center gap-2">
<StatusIcon className={cn('w-4 h-4', config.colorClass)} />
<span className={cn('text-xs font-medium', config.colorClass)}>
{config.label}
</span>
<span className={cn('text-xs font-medium', config.colorClass)}>{config.label}</span>
</div>
<div className="flex items-center gap-1">
{/* Priority badge */}
{priorityConf && (
<span className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}>
<span
className={cn(
'text-[10px] font-bold px-1.5 py-0.5 rounded',
priorityConf.colorClass
)}
>
{data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'}
</span>
)}
@@ -161,11 +170,7 @@ export const TaskNode = memo(function TaskNode({
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-background/50"
>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 hover:bg-background/50">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
@@ -212,9 +217,7 @@ export const TaskNode = memo(function TaskNode({
{data.isRunning && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1.5 bg-muted rounded-full overflow-hidden">
<div
className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate"
/>
<div className="h-full bg-[var(--status-in-progress)] rounded-full animate-progress-indeterminate" />
</div>
<span className="text-[10px] text-muted-foreground">Running...</span>
</div>
@@ -240,7 +243,8 @@ export const TaskNode = memo(function TaskNode({
'hover:!bg-brand-500',
data.status === 'completed' || data.status === 'verified'
? '!bg-[var(--status-success)]'
: ''
: '',
isDimmed && 'opacity-30'
)}
/>
</>

View File

@@ -14,8 +14,20 @@ import {
import '@xyflow/react/dist/style.css';
import { Feature } from '@/store/app-store';
import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks';
import {
TaskNode,
DependencyEdge,
GraphControls,
GraphLegend,
GraphFilterControls,
} from './components';
import {
useGraphNodes,
useGraphLayout,
useGraphFilter,
type TaskNodeData,
type GraphFilterState,
} from './hooks';
import { cn } from '@/lib/utils';
// Define custom node and edge types - using any to avoid React Flow's strict typing
@@ -32,6 +44,8 @@ const edgeTypes: any = {
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onNodeClick?: (featureId: string) => void;
onNodeDoubleClick?: (featureId: string) => void;
backgroundStyle?: React.CSSProperties;
@@ -41,6 +55,8 @@ interface GraphCanvasProps {
function GraphCanvasInner({
features,
runningAutoTasks,
searchQuery,
onSearchQueryChange,
onNodeClick,
onNodeDoubleClick,
backgroundStyle,
@@ -49,10 +65,25 @@ function GraphCanvasInner({
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Transform features to nodes and edges
// Filter state (category and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
// Combined filter state
const filterState: GraphFilterState = {
searchQuery,
selectedCategories,
isNegativeFilter,
};
// Calculate filter results
const filterResult = useGraphFilter(features, filterState);
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
runningAutoTasks,
filterResult,
});
// Apply layout
@@ -80,6 +111,13 @@ function GraphCanvasInner({
[runLayout]
);
// Handle clear all filters
const handleClearFilters = useCallback(() => {
onSearchQueryChange('');
setSelectedCategories([]);
setIsNegativeFilter(false);
}, [onSearchQueryChange]);
// Handle node click
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
@@ -158,6 +196,16 @@ function GraphCanvasInner({
layoutDirection={layoutDirection}
/>
<GraphFilterControls
filterState={filterState}
availableCategories={filterResult.availableCategories}
hasActiveFilter={filterResult.hasActiveFilter}
onSearchQueryChange={onSearchQueryChange}
onCategoriesChange={setSelectedCategories}
onNegativeFilterChange={setIsNegativeFilter}
onClearFilters={handleClearFilters}
/>
<GraphLegend />
</ReactFlow>
</div>

View File

@@ -9,6 +9,8 @@ interface GraphViewProps {
currentWorktreePath: string | null;
currentWorktreeBranch: string | null;
projectPath: string | null;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onEditFeature: (feature: Feature) => void;
onViewOutput: (feature: Feature) => void;
}
@@ -19,6 +21,8 @@ export function GraphView({
currentWorktreePath,
currentWorktreeBranch,
projectPath,
searchQuery,
onSearchQueryChange,
onEditFeature,
onViewOutput,
}: GraphViewProps) {
@@ -79,6 +83,8 @@ export function GraphView({
<GraphCanvas
features={filteredFeatures}
runningAutoTasks={runningAutoTasks}
searchQuery={searchQuery}
onSearchQueryChange={onSearchQueryChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
backgroundStyle={backgroundImageStyle}

View File

@@ -1,2 +1,8 @@
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
export {
useGraphNodes,
type TaskNode,
type DependencyEdge,
type TaskNodeData,
} from './use-graph-nodes';
export { useGraphLayout } from './use-graph-layout';
export { useGraphFilter, type GraphFilterState, type GraphFilterResult } from './use-graph-filter';

View File

@@ -0,0 +1,165 @@
import { useMemo } from 'react';
import { Feature } from '@/store/app-store';
export interface GraphFilterState {
searchQuery: string;
selectedCategories: string[];
isNegativeFilter: boolean;
}
export interface GraphFilterResult {
matchedNodeIds: Set<string>;
highlightedNodeIds: Set<string>;
highlightedEdgeIds: Set<string>;
availableCategories: string[];
hasActiveFilter: boolean;
}
/**
* Traverses up the dependency tree to find all ancestors of a node
*/
function getAncestors(
featureId: string,
featureMap: Map<string, Feature>,
visited: Set<string>
): void {
if (visited.has(featureId)) return;
visited.add(featureId);
const feature = featureMap.get(featureId);
if (!feature?.dependencies) return;
for (const depId of feature.dependencies) {
if (featureMap.has(depId)) {
getAncestors(depId, featureMap, visited);
}
}
}
/**
* Traverses down to find all descendants (features that depend on this one)
*/
function getDescendants(featureId: string, features: Feature[], visited: Set<string>): void {
if (visited.has(featureId)) return;
visited.add(featureId);
for (const feature of features) {
if (feature.dependencies?.includes(featureId)) {
getDescendants(feature.id, features, visited);
}
}
}
/**
* Gets all edges in the highlighted path
*/
function getHighlightedEdges(highlightedNodeIds: Set<string>, features: Feature[]): Set<string> {
const edges = new Set<string>();
for (const feature of features) {
if (!highlightedNodeIds.has(feature.id)) continue;
if (!feature.dependencies) continue;
for (const depId of feature.dependencies) {
if (highlightedNodeIds.has(depId)) {
edges.add(`${depId}->${feature.id}`);
}
}
}
return edges;
}
/**
* Hook to calculate graph filter results based on search query, categories, and filter mode
*/
export function useGraphFilter(
features: Feature[],
filterState: GraphFilterState
): GraphFilterResult {
const { searchQuery, selectedCategories, isNegativeFilter } = filterState;
return useMemo(() => {
// Extract all unique categories
const availableCategories = Array.from(
new Set(features.map((f) => f.category).filter(Boolean))
).sort();
const normalizedQuery = searchQuery.toLowerCase().trim();
const hasSearchQuery = normalizedQuery.length > 0;
const hasCategoryFilter = selectedCategories.length > 0;
const hasActiveFilter = hasSearchQuery || hasCategoryFilter || isNegativeFilter;
// If no filters active, return empty sets (show all nodes normally)
if (!hasActiveFilter) {
return {
matchedNodeIds: new Set<string>(),
highlightedNodeIds: new Set<string>(),
highlightedEdgeIds: new Set<string>(),
availableCategories,
hasActiveFilter: false,
};
}
// Find directly matched nodes
const matchedNodeIds = new Set<string>();
const featureMap = new Map(features.map((f) => [f.id, f]));
for (const feature of features) {
let matchesSearch = true;
let matchesCategory = true;
// Check search query match (title or description)
if (hasSearchQuery) {
const titleMatch = feature.title?.toLowerCase().includes(normalizedQuery);
const descMatch = feature.description?.toLowerCase().includes(normalizedQuery);
matchesSearch = titleMatch || descMatch;
}
// Check category match
if (hasCategoryFilter) {
matchesCategory = selectedCategories.includes(feature.category);
}
// Both conditions must be true for a match
if (matchesSearch && matchesCategory) {
matchedNodeIds.add(feature.id);
}
}
// Apply negative filter if enabled (invert the matched set)
let effectiveMatchedIds: Set<string>;
if (isNegativeFilter) {
effectiveMatchedIds = new Set(
features.filter((f) => !matchedNodeIds.has(f.id)).map((f) => f.id)
);
} else {
effectiveMatchedIds = matchedNodeIds;
}
// Calculate full path (ancestors + descendants) for highlighted nodes
const highlightedNodeIds = new Set<string>();
for (const id of effectiveMatchedIds) {
// Add the matched node itself
highlightedNodeIds.add(id);
// Add all ancestors (dependencies)
getAncestors(id, featureMap, highlightedNodeIds);
// Add all descendants (dependents)
getDescendants(id, features, highlightedNodeIds);
}
// Get edges in the highlighted path
const highlightedEdgeIds = getHighlightedEdges(highlightedNodeIds, features);
return {
matchedNodeIds: effectiveMatchedIds,
highlightedNodeIds,
highlightedEdgeIds,
availableCategories,
hasActiveFilter: true,
};
}, [features, searchQuery, selectedCategories, isNegativeFilter]);
}

View File

@@ -2,26 +2,37 @@ import { useMemo } from 'react';
import { Node, Edge } from '@xyflow/react';
import { Feature } from '@/store/app-store';
import { getBlockingDependencies } from '@automaker/dependency-resolver';
import { GraphFilterResult } from './use-graph-filter';
export interface TaskNodeData extends Feature {
isBlocked: boolean;
isRunning: boolean;
blockingDependencies: string[];
// Filter highlight states
isMatched?: boolean;
isHighlighted?: boolean;
isDimmed?: boolean;
}
export type TaskNode = Node<TaskNodeData, 'task'>;
export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>;
export type DependencyEdge = Edge<{
sourceStatus: Feature['status'];
targetStatus: Feature['status'];
isHighlighted?: boolean;
isDimmed?: boolean;
}>;
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
filterResult?: GraphFilterResult;
}
/**
* Transforms features into React Flow nodes and edges
* Creates dependency edges based on feature.dependencies array
*/
export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) {
export function useGraphNodes({ features, runningAutoTasks, filterResult }: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
@@ -30,11 +41,22 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
// Create feature map for quick lookups
features.forEach((f) => featureMap.set(f.id, f));
// Extract filter state
const hasActiveFilter = filterResult?.hasActiveFilter ?? false;
const matchedNodeIds = filterResult?.matchedNodeIds ?? new Set<string>();
const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set<string>();
const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set<string>();
// Create nodes
features.forEach((feature) => {
const isRunning = runningAutoTasks.includes(feature.id);
const blockingDeps = getBlockingDependencies(feature, features);
// Calculate filter highlight states
const isMatched = hasActiveFilter && matchedNodeIds.has(feature.id);
const isHighlighted = hasActiveFilter && highlightedNodeIds.has(feature.id);
const isDimmed = hasActiveFilter && !highlightedNodeIds.has(feature.id);
const node: TaskNode = {
id: feature.id,
type: 'task',
@@ -44,6 +66,10 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
isBlocked: blockingDeps.length > 0,
isRunning,
blockingDependencies: blockingDeps,
// Filter states
isMatched,
isHighlighted,
isDimmed,
},
};
@@ -55,8 +81,14 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
// Only create edge if the dependency exists in current view
if (featureMap.has(depId)) {
const sourceFeature = featureMap.get(depId)!;
const edgeId = `${depId}->${feature.id}`;
// Calculate edge highlight states
const edgeIsHighlighted = hasActiveFilter && highlightedEdgeIds.has(edgeId);
const edgeIsDimmed = hasActiveFilter && !highlightedEdgeIds.has(edgeId);
const edge: DependencyEdge = {
id: `${depId}->${feature.id}`,
id: edgeId,
source: depId,
target: feature.id,
type: 'dependency',
@@ -64,6 +96,8 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
},
};
edgeList.push(edge);
@@ -73,7 +107,7 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks]);
}, [features, runningAutoTasks, filterResult]);
return { nodes, edges };
}

View File

@@ -1049,12 +1049,79 @@
border-radius: 0 !important;
}
/* Graph Filter Highlight States */
/* Matched node - direct search/filter match */
.graph-node-matched {
box-shadow:
0 0 0 3px var(--brand-500),
0 0 20px 4px var(--brand-500);
border-color: var(--brand-500) !important;
z-index: 10;
}
/* Animated glow for matched nodes */
@keyframes matched-node-glow {
0%,
100% {
box-shadow:
0 0 0 3px var(--brand-500),
0 0 15px 2px var(--brand-500);
}
50% {
box-shadow:
0 0 0 3px var(--brand-500),
0 0 25px 6px var(--brand-500);
}
}
.graph-node-matched {
animation: matched-node-glow 2s ease-in-out infinite;
}
/* Highlighted path node - part of the dependency path */
.graph-node-highlighted {
box-shadow:
0 0 0 2px var(--brand-400),
0 0 12px 2px var(--brand-400);
z-index: 5;
}
/* Dimmed node - not part of filter results */
.graph-node-dimmed {
opacity: 0.25;
filter: grayscale(60%);
transition:
opacity 0.3s ease,
filter 0.3s ease;
}
.graph-node-dimmed:hover {
opacity: 0.4;
filter: grayscale(40%);
}
/* Highlighted edge styles */
.graph-edge-highlighted path {
stroke: var(--brand-500) !important;
stroke-width: 4px !important;
filter: drop-shadow(0 0 6px var(--brand-500));
}
/* Dimmed edge styles */
.graph-edge-dimmed path {
opacity: 0.15;
stroke-width: 1px !important;
filter: none !important;
}
/* Reduce motion preference */
@media (prefers-reduced-motion: reduce) {
.graph-canvas .animated-edge path,
.graph-canvas .edge-flowing path,
.animate-pulse-subtle,
.animate-progress-indeterminate {
.animate-progress-indeterminate,
.graph-node-matched {
animation: none;
}
}