Merge pull request #226 from JBotwina/graph-filtering-and-node-controls

feat: Graph Filtering and Node Controls
This commit is contained in:
Web Dev Cody
2025-12-22 21:18:19 -05:00
committed by GitHub
16 changed files with 1046 additions and 159 deletions

View File

@@ -74,10 +74,11 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"react-resizable-panels": "^3.0.6",
"rehype-raw": "^7.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
"zustand": "^5.0.9"
},
"optionalDependencies": {

View File

@@ -1036,8 +1036,13 @@ export function BoardView() {
currentWorktreePath={currentWorktreePath}
currentWorktreeBranch={currentWorktreeBranch}
projectPath={currentProject?.path || null}
searchQuery={searchQuery}
onSearchQueryChange={setSearchQuery}
onEditFeature={(feature) => setEditingFeature(feature)}
onViewOutput={handleViewOutput}
onStartTask={handleStartImplementation}
onStopTask={handleForceStopFeature}
onResumeTask={handleResumeFeature}
/>
)}
</div>

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

@@ -31,7 +31,7 @@ export function GraphControls({
return (
<Panel position="bottom-left" className="flex flex-col gap-2">
<TooltipProvider delayDuration={200}>
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg">
<div className="flex flex-col gap-1 p-1.5 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
{/* Zoom controls */}
<Tooltip>
<TooltipTrigger asChild>
@@ -120,22 +120,13 @@ export function GraphControls({
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 w-8 p-0',
isLocked && 'bg-brand-500/20 text-brand-500'
)}
className={cn('h-8 w-8 p-0', isLocked && 'bg-brand-500/20 text-brand-500')}
onClick={onToggleLock}
>
{isLocked ? (
<Lock className="w-4 h-4" />
) : (
<Unlock className="w-4 h-4" />
)}
{isLocked ? <Lock className="w-4 h-4" /> : <Unlock className="w-4 h-4" />}
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
</TooltipContent>
<TooltipContent side="right">{isLocked ? 'Unlock Nodes' : 'Lock Nodes'}</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>

View File

@@ -0,0 +1,329 @@
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,
Play,
Pause,
Clock,
CheckCircle2,
CircleDot,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import {
GraphFilterState,
STATUS_FILTER_OPTIONS,
StatusFilterValue,
} from '../hooks/use-graph-filter';
// Status display configuration
const statusDisplayConfig: Record<
StatusFilterValue,
{ label: string; icon: typeof Play; colorClass: string }
> = {
running: { label: 'Running', icon: Play, colorClass: 'text-[var(--status-in-progress)]' },
paused: { label: 'Paused', icon: Pause, colorClass: 'text-[var(--status-warning)]' },
backlog: { label: 'Backlog', icon: Clock, colorClass: 'text-muted-foreground' },
waiting_approval: {
label: 'Waiting Approval',
icon: CircleDot,
colorClass: 'text-[var(--status-waiting)]',
},
verified: { label: 'Verified', icon: CheckCircle2, colorClass: 'text-[var(--status-success)]' },
};
interface GraphFilterControlsProps {
filterState: GraphFilterState;
availableCategories: string[];
hasActiveFilter: boolean;
onCategoriesChange: (categories: string[]) => void;
onStatusesChange: (statuses: string[]) => void;
onNegativeFilterChange: (isNegative: boolean) => void;
onClearFilters: () => void;
}
export function GraphFilterControls({
filterState,
availableCategories,
hasActiveFilter,
onCategoriesChange,
onStatusesChange,
onNegativeFilterChange,
onClearFilters,
}: GraphFilterControlsProps) {
const { selectedCategories, selectedStatuses, 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 handleStatusToggle = (status: string) => {
if (selectedStatuses.includes(status)) {
onStatusesChange(selectedStatuses.filter((s) => s !== status));
} else {
onStatusesChange([...selectedStatuses, status]);
}
};
const handleSelectAllStatuses = () => {
if (selectedStatuses.length === STATUS_FILTER_OPTIONS.length) {
onStatusesChange([]);
} else {
onStatusesChange([...STATUS_FILTER_OPTIONS]);
}
};
const categoryButtonLabel =
selectedCategories.length === 0
? 'All Categories'
: selectedCategories.length === 1
? selectedCategories[0]
: `${selectedCategories.length} Categories`;
const statusButtonLabel =
selectedStatuses.length === 0
? 'All Statuses'
: selectedStatuses.length === 1
? statusDisplayConfig[selectedStatuses[0] as StatusFilterValue]?.label ||
selectedStatuses[0]
: `${selectedStatuses.length} Statuses`;
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>
{/* Status Filter Dropdown */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className={cn(
'h-8 px-2 gap-1.5',
selectedStatuses.length > 0 && 'bg-brand-500/20 text-brand-500'
)}
>
<CircleDot className="w-4 h-4" />
<span className="text-xs max-w-[120px] truncate">{statusButtonLabel}</span>
<ChevronDown className="w-3 h-3 opacity-50" />
</Button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>Filter by Status</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">Status</div>
{/* Select All option */}
<div
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={handleSelectAllStatuses}
>
<Checkbox
checked={selectedStatuses.length === STATUS_FILTER_OPTIONS.length}
onCheckedChange={handleSelectAllStatuses}
/>
<span className="text-sm font-medium">
{selectedStatuses.length === STATUS_FILTER_OPTIONS.length
? 'Deselect All'
: 'Select All'}
</span>
</div>
<div className="h-px bg-border" />
{/* Status list */}
<div className="space-y-0.5">
{STATUS_FILTER_OPTIONS.map((status) => {
const config = statusDisplayConfig[status];
const StatusIcon = config.icon;
return (
<div
key={status}
className="flex items-center gap-2 px-2 py-1.5 rounded hover:bg-accent cursor-pointer"
onClick={() => handleStatusToggle(status)}
>
<Checkbox
checked={selectedStatuses.includes(status)}
onCheckedChange={() => handleStatusToggle(status)}
/>
<StatusIcon className={cn('w-3.5 h-3.5', config.colorClass)} />
<span className="text-sm">{config.label}</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)}
aria-label={
isNegativeFilter
? 'Switch to show matching nodes'
: 'Switch to hide matching nodes'
}
aria-pressed={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}
aria-label="Toggle between show and hide filter modes"
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}
aria-label="Clear all filters"
>
<X className="w-4 h-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Clear All Filters</TooltipContent>
</Tooltip>
</>
)}
</div>
</TooltipProvider>
</Panel>
);
}

View File

@@ -1,12 +1,5 @@
import { Panel } from '@xyflow/react';
import {
Clock,
Play,
Pause,
CheckCircle2,
Lock,
AlertCircle,
} from 'lucide-react';
import { Clock, Play, Pause, CheckCircle2, Lock, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
const legendItems = [
@@ -51,7 +44,7 @@ const legendItems = [
export function GraphLegend() {
return (
<Panel position="bottom-right" className="pointer-events-none">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto">
<div className="flex flex-wrap gap-3 p-2 rounded-lg bg-popover/90 backdrop-blur-sm border border-border shadow-lg pointer-events-auto text-popover-foreground">
{legendItems.map((item) => {
const Icon = item.icon;
return (

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

@@ -10,8 +10,10 @@ import {
Play,
Pause,
Eye,
MoreHorizontal,
MoreVertical,
GitBranch,
Terminal,
RotateCcw,
} from 'lucide-react';
import { TaskNodeData } from '../hooks/use-graph-nodes';
import { Button } from '@/components/ui/button';
@@ -19,7 +21,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
@@ -72,14 +73,19 @@ 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;
// Task is stopped if it's in_progress but not actively running
const isStopped = data.status === 'in_progress' && !data.isRunning;
return (
<>
{/* Target handle (left side - receives dependencies) */}
@@ -89,39 +95,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>
)}
@@ -158,39 +171,101 @@ export const TaskNode = memo(function TaskNode({
</TooltipProvider>
)}
{/* Stopped indicator - task is in_progress but not actively running */}
{isStopped && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<div className="p-1 rounded bg-[var(--status-warning-bg)]">
<Pause className="w-3 h-3 text-[var(--status-warning)]" />
</div>
</TooltipTrigger>
<TooltipContent side="top" className="text-xs max-w-[200px]">
<p>Task paused - click menu to resume</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{/* Actions dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 hover:bg-background/50"
className={cn(
'h-7 w-7 p-0 rounded-md',
'bg-background/60 hover:bg-background',
'border border-border/50 hover:border-border',
'shadow-sm',
'transition-all duration-150'
)}
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="w-4 h-4" />
<MoreVertical className="w-4 h-4 text-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-40">
<DropdownMenuItem className="text-xs">
<DropdownMenuContent
align="end"
className="w-44"
onClick={(e) => e.stopPropagation()}
>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewLogs?.();
}}
>
<Terminal className="w-3 h-3 mr-2" />
View Agent Logs
</DropdownMenuItem>
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onViewDetails?.();
}}
>
<Eye className="w-3 h-3 mr-2" />
View Details
</DropdownMenuItem>
{data.status === 'backlog' && !data.isBlocked && (
<DropdownMenuItem className="text-xs">
<DropdownMenuItem
className="text-xs cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStartTask?.();
}}
>
<Play className="w-3 h-3 mr-2" />
Start Task
</DropdownMenuItem>
)}
{data.isRunning && (
<DropdownMenuItem className="text-xs text-[var(--status-error)]">
<DropdownMenuItem
className="text-xs text-[var(--status-error)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onStopTask?.();
}}
>
<Pause className="w-3 h-3 mr-2" />
Stop Task
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem className="text-xs">
<GitBranch className="w-3 h-3 mr-2" />
View Branch
</DropdownMenuItem>
{isStopped && (
<DropdownMenuItem
className="text-xs text-[var(--status-success)] cursor-pointer"
onClick={(e) => {
e.stopPropagation();
data.onResumeTask?.();
}}
>
<RotateCcw className="w-3 h-3 mr-2" />
Resume Task
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
@@ -212,14 +287,22 @@ 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>
)}
{/* Paused indicator for stopped tasks */}
{isStopped && (
<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 w-1/2 bg-[var(--status-warning)] rounded-full" />
</div>
<span className="text-[10px] text-[var(--status-warning)] font-medium">Paused</span>
</div>
)}
{/* Branch name if assigned */}
{data.branchName && (
<div className="mt-2 flex items-center gap-1 text-[10px] text-muted-foreground">
@@ -240,7 +323,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

@@ -4,6 +4,7 @@ import {
Background,
BackgroundVariant,
MiniMap,
Panel,
useNodesState,
useEdgesState,
ReactFlowProvider,
@@ -14,9 +15,25 @@ 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,
type NodeActionCallbacks,
} from './hooks';
import { cn } from '@/lib/utils';
import { useDebounceValue } from 'usehooks-ts';
import { SearchX } from 'lucide-react';
import { Button } from '@/components/ui/button';
// Define custom node and edge types - using any to avoid React Flow's strict typing
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -32,8 +49,10 @@ const edgeTypes: any = {
interface GraphCanvasProps {
features: Feature[];
runningAutoTasks: string[];
onNodeClick?: (featureId: string) => void;
searchQuery: string;
onSearchQueryChange: (query: string) => void;
onNodeDoubleClick?: (featureId: string) => void;
nodeActionCallbacks?: NodeActionCallbacks;
backgroundStyle?: React.CSSProperties;
className?: string;
}
@@ -41,18 +60,41 @@ interface GraphCanvasProps {
function GraphCanvasInner({
features,
runningAutoTasks,
onNodeClick,
searchQuery,
onSearchQueryChange,
onNodeDoubleClick,
nodeActionCallbacks,
backgroundStyle,
className,
}: GraphCanvasProps) {
const [isLocked, setIsLocked] = useState(false);
const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR');
// Transform features to nodes and edges
// Filter state (category, status, and negative toggle are local to graph view)
const [selectedCategories, setSelectedCategories] = useState<string[]>([]);
const [selectedStatuses, setSelectedStatuses] = useState<string[]>([]);
const [isNegativeFilter, setIsNegativeFilter] = useState(false);
// Debounce search query for performance with large graphs
const [debouncedSearchQuery] = useDebounceValue(searchQuery, 200);
// Combined filter state
const filterState: GraphFilterState = {
searchQuery: debouncedSearchQuery,
selectedCategories,
selectedStatuses,
isNegativeFilter,
};
// Calculate filter results
const filterResult = useGraphFilter(features, filterState, runningAutoTasks);
// Transform features to nodes and edges with filter results
const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({
features,
runningAutoTasks,
filterResult,
actionCallbacks: nodeActionCallbacks,
});
// Apply layout
@@ -80,13 +122,13 @@ function GraphCanvasInner({
[runLayout]
);
// Handle node click
const handleNodeClick = useCallback(
(_event: React.MouseEvent, node: Node<TaskNodeData>) => {
onNodeClick?.(node.id);
},
[onNodeClick]
);
// Handle clear all filters
const handleClearFilters = useCallback(() => {
onSearchQueryChange('');
setSelectedCategories([]);
setSelectedStatuses([]);
setIsNegativeFilter(false);
}, [onSearchQueryChange]);
// Handle node double click
const handleNodeDoubleClick = useCallback(
@@ -122,7 +164,6 @@ function GraphCanvasInner({
edges={edges}
onNodesChange={isLocked ? undefined : onNodesChange}
onEdgesChange={onEdgesChange}
onNodeClick={handleNodeClick}
onNodeDoubleClick={handleNodeDoubleClick}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
@@ -158,7 +199,35 @@ function GraphCanvasInner({
layoutDirection={layoutDirection}
/>
<GraphFilterControls
filterState={filterState}
availableCategories={filterResult.availableCategories}
hasActiveFilter={filterResult.hasActiveFilter}
onCategoriesChange={setSelectedCategories}
onStatusesChange={setSelectedStatuses}
onNegativeFilterChange={setIsNegativeFilter}
onClearFilters={handleClearFilters}
/>
<GraphLegend />
{/* Empty state when all nodes are filtered out */}
{filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && (
<Panel position="top-center" className="mt-20">
<div className="flex flex-col items-center gap-3 p-6 rounded-lg bg-popover/95 backdrop-blur-sm border border-border shadow-lg text-popover-foreground">
<SearchX className="w-10 h-10 text-muted-foreground" />
<div className="text-center">
<p className="text-sm font-medium">No matching tasks</p>
<p className="text-xs text-muted-foreground mt-1">
Try adjusting your filters or search query
</p>
</div>
<Button variant="outline" size="sm" onClick={handleClearFilters} className="mt-1">
Clear Filters
</Button>
</div>
</Panel>
)}
</ReactFlow>
</div>
);

View File

@@ -2,6 +2,7 @@ import { useMemo, useCallback } from 'react';
import { Feature, useAppStore } from '@/store/app-store';
import { GraphCanvas } from './graph-canvas';
import { useBoardBackground } from '../board-view/hooks';
import { NodeActionCallbacks } from './hooks';
interface GraphViewProps {
features: Feature[];
@@ -9,8 +10,13 @@ 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;
onStartTask?: (feature: Feature) => void;
onStopTask?: (feature: Feature) => void;
onResumeTask?: (feature: Feature) => void;
}
export function GraphView({
@@ -19,8 +25,13 @@ export function GraphView({
currentWorktreePath,
currentWorktreeBranch,
projectPath,
searchQuery,
onSearchQueryChange,
onEditFeature,
onViewOutput,
onStartTask,
onStopTask,
onResumeTask,
}: GraphViewProps) {
const { currentProject } = useAppStore();
@@ -35,7 +46,7 @@ export function GraphView({
// Skip completed features (they're in archive)
if (f.status === 'completed') return false;
const featureBranch = f.branchName;
const featureBranch = f.branchName as string | undefined;
if (!featureBranch) {
// No branch assigned - show only on primary worktree
@@ -52,17 +63,6 @@ export function GraphView({
});
}, [features, currentWorktreePath, currentWorktreeBranch, projectPath]);
// Handle node click - view details
const handleNodeClick = useCallback(
(featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
[features, onViewOutput]
);
// Handle node double click - edit
const handleNodeDoubleClick = useCallback(
(featureId: string) => {
@@ -74,13 +74,52 @@ export function GraphView({
[features, onEditFeature]
);
// Node action callbacks for dropdown menu
const nodeActionCallbacks: NodeActionCallbacks = useMemo(
() => ({
onViewLogs: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onViewOutput(feature);
}
},
onViewDetails: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onEditFeature(feature);
}
},
onStartTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStartTask?.(feature);
}
},
onStopTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onStopTask?.(feature);
}
},
onResumeTask: (featureId: string) => {
const feature = features.find((f) => f.id === featureId);
if (feature) {
onResumeTask?.(feature);
}
},
}),
[features, onViewOutput, onEditFeature, onStartTask, onStopTask, onResumeTask]
);
return (
<div className="flex-1 overflow-hidden relative">
<GraphCanvas
features={filteredFeatures}
runningAutoTasks={runningAutoTasks}
onNodeClick={handleNodeClick}
searchQuery={searchQuery}
onSearchQueryChange={onSearchQueryChange}
onNodeDoubleClick={handleNodeDoubleClick}
nodeActionCallbacks={nodeActionCallbacks}
backgroundStyle={backgroundImageStyle}
className="h-full"
/>

View File

@@ -1,2 +1,9 @@
export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes';
export {
useGraphNodes,
type TaskNode,
type DependencyEdge,
type TaskNodeData,
type NodeActionCallbacks,
} 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,209 @@
import { useMemo } from 'react';
import { Feature } from '@/store/app-store';
export interface GraphFilterState {
searchQuery: string;
selectedCategories: string[];
selectedStatuses: string[];
isNegativeFilter: boolean;
}
// Available status filter values
export const STATUS_FILTER_OPTIONS = [
'running',
'paused',
'backlog',
'waiting_approval',
'verified',
] as const;
export type StatusFilterValue = (typeof STATUS_FILTER_OPTIONS)[number];
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;
const deps = feature.dependencies as string[] | undefined;
if (!deps) return;
for (const depId of deps) {
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) {
const deps = feature.dependencies as string[] | undefined;
if (deps?.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;
const deps = feature.dependencies as string[] | undefined;
if (!deps) continue;
for (const depId of deps) {
if (highlightedNodeIds.has(depId)) {
edges.add(`${depId}->${feature.id}`);
}
}
}
return edges;
}
/**
* Gets the effective status of a feature (accounting for running state)
*/
function getEffectiveStatus(feature: Feature, runningAutoTasks: string[]): StatusFilterValue {
if (feature.status === 'in_progress') {
return runningAutoTasks.includes(feature.id) ? 'running' : 'paused';
}
return feature.status as StatusFilterValue;
}
/**
* Hook to calculate graph filter results based on search query, categories, statuses, and filter mode
*/
export function useGraphFilter(
features: Feature[],
filterState: GraphFilterState,
runningAutoTasks: string[] = []
): GraphFilterResult {
const { searchQuery, selectedCategories, selectedStatuses, 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 hasStatusFilter = selectedStatuses.length > 0;
const hasActiveFilter =
hasSearchQuery || hasCategoryFilter || hasStatusFilter || 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;
let matchesStatus = 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);
}
// Check status match
if (hasStatusFilter) {
const effectiveStatus = getEffectiveStatus(feature, runningAutoTasks);
matchesStatus = selectedStatuses.includes(effectiveStatus);
}
// All conditions must be true for a match
if (matchesSearch && matchesCategory && matchesStatus) {
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,
selectedStatuses,
isNegativeFilter,
runningAutoTasks,
]);
}

View File

@@ -2,26 +2,63 @@ 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 {
// Re-declare properties from BaseFeature that have index signature issues
priority?: number;
error?: string;
branchName?: string;
dependencies?: string[];
// Task node specific properties
isBlocked: boolean;
isRunning: boolean;
blockingDependencies: string[];
// Filter highlight states
isMatched?: boolean;
isHighlighted?: boolean;
isDimmed?: boolean;
// Action callbacks
onViewLogs?: () => void;
onViewDetails?: () => void;
onStartTask?: () => void;
onStopTask?: () => void;
onResumeTask?: () => void;
}
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;
}>;
export interface NodeActionCallbacks {
onViewLogs?: (featureId: string) => void;
onViewDetails?: (featureId: string) => void;
onStartTask?: (featureId: string) => void;
onStopTask?: (featureId: string) => void;
onResumeTask?: (featureId: string) => void;
}
interface UseGraphNodesProps {
features: Feature[];
runningAutoTasks: string[];
filterResult?: GraphFilterResult;
actionCallbacks?: NodeActionCallbacks;
}
/**
* 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,
actionCallbacks,
}: UseGraphNodesProps) {
const { nodes, edges } = useMemo(() => {
const nodeList: TaskNode[] = [];
const edgeList: DependencyEdge[] = [];
@@ -30,11 +67,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,19 +92,46 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
isBlocked: blockingDeps.length > 0,
isRunning,
blockingDependencies: blockingDeps,
// Filter states
isMatched,
isHighlighted,
isDimmed,
// Action callbacks (bound to this feature's ID)
onViewLogs: actionCallbacks?.onViewLogs
? () => actionCallbacks.onViewLogs!(feature.id)
: undefined,
onViewDetails: actionCallbacks?.onViewDetails
? () => actionCallbacks.onViewDetails!(feature.id)
: undefined,
onStartTask: actionCallbacks?.onStartTask
? () => actionCallbacks.onStartTask!(feature.id)
: undefined,
onStopTask: actionCallbacks?.onStopTask
? () => actionCallbacks.onStopTask!(feature.id)
: undefined,
onResumeTask: actionCallbacks?.onResumeTask
? () => actionCallbacks.onResumeTask!(feature.id)
: undefined,
},
};
nodeList.push(node);
// Create edges for dependencies
if (feature.dependencies && feature.dependencies.length > 0) {
feature.dependencies.forEach((depId: string) => {
const deps = feature.dependencies as string[] | undefined;
if (deps && deps.length > 0) {
deps.forEach((depId: string) => {
// 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 +139,8 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
data: {
sourceStatus: sourceFeature.status,
targetStatus: feature.status,
isHighlighted: edgeIsHighlighted,
isDimmed: edgeIsDimmed,
},
};
edgeList.push(edge);
@@ -73,7 +150,7 @@ export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps
});
return { nodes: nodeList, edges: edgeList };
}, [features, runningAutoTasks]);
}, [features, runningAutoTasks, filterResult, actionCallbacks]);
return { nodes, edges };
}

View File

@@ -1,4 +1,9 @@
export { GraphView } from './graph-view';
export { GraphCanvas } from './graph-canvas';
export { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components';
export { useGraphNodes, useGraphLayout, type TaskNode as TaskNodeType, type DependencyEdge as DependencyEdgeType } from './hooks';
export {
useGraphNodes,
useGraphLayout,
type TaskNode as TaskNodeType,
type DependencyEdge as DependencyEdgeType,
} from './hooks';

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;
}
}

110
package-lock.json generated
View File

@@ -115,6 +115,7 @@
"rehype-raw": "^7.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"usehooks-ts": "^3.1.1",
"zustand": "^5.0.9"
},
"devDependencies": {
@@ -421,6 +422,7 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5",
@@ -1004,6 +1006,7 @@
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.4.tgz",
"integrity": "sha512-xMF6OfEAUVY5Waega4juo1QGACfNkNF+aJLqpd8oUJz96ms2zbfQ9Gh35/tI3y8akEV31FruKfj7hBnIU/nkqA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@codemirror/state": "^6.5.0",
"crelt": "^1.0.6",
@@ -1046,6 +1049,7 @@
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dnd-kit/accessibility": "^3.1.1",
"@dnd-kit/utilities": "^3.2.2",
@@ -1866,7 +1870,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1888,7 +1891,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1905,7 +1907,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1920,7 +1921,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -2676,7 +2676,6 @@
"integrity": "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==",
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -2801,7 +2800,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2818,7 +2816,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2835,7 +2832,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -2944,7 +2940,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -2967,7 +2962,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -2990,7 +2984,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3076,7 +3069,6 @@
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
@@ -3099,7 +3091,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3119,7 +3110,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -3458,8 +3448,7 @@
"version": "16.0.10",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
"integrity": "sha512-8tuaQkyDVgeONQ1MeT9Mkk8pQmZapMKFh5B+OrFUlG3rVmYTXcXlBetBgTurKXGaIZvkoqRT9JL5K3phXcgang==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.0.10",
@@ -3473,7 +3462,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3490,7 +3478,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3507,7 +3494,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3524,7 +3510,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3541,7 +3526,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3558,7 +3542,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3575,7 +3558,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3592,7 +3574,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 10"
}
@@ -3683,6 +3664,7 @@
"integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"playwright": "1.57.0"
},
@@ -5093,7 +5075,6 @@
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz",
"integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==",
"license": "Apache-2.0",
"peer": true,
"dependencies": {
"tslib": "^2.8.0"
}
@@ -5427,6 +5408,7 @@
"resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.141.6.tgz",
"integrity": "sha512-qWFxi2D6eGc1L03RzUuhyEOplZ7Q6q62YOl7Of9Y0q4YjwQwxRm4zxwDVtvUIoy4RLVCpqp5UoE+Nxv2PY9trg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@tanstack/history": "1.141.0",
"@tanstack/react-store": "^0.8.0",
@@ -5978,6 +5960,7 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
"integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -5988,6 +5971,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -6093,6 +6077,7 @@
"integrity": "sha512-6/cmF2piao+f6wSxUsJLZjck7OQsYyRtcOZS02k7XINSNlz93v6emM8WutDQSXnroG2xwYlEVHJI+cPA7CPM3Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.50.0",
"@typescript-eslint/types": "8.50.0",
@@ -6586,7 +6571,8 @@
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/@xyflow/react": {
"version": "12.10.0",
@@ -6684,6 +6670,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6744,6 +6731,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7303,6 +7291,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -7834,8 +7823,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/cliui": {
"version": "8.0.1",
@@ -8121,8 +8109,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8219,6 +8206,7 @@
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
"integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==",
"license": "ISC",
"peer": true,
"engines": {
"node": ">=12"
}
@@ -8520,6 +8508,7 @@
"integrity": "sha512-59CAAjAhTaIMCN8y9kD573vDkxbs1uhDcrFLHSgutYdPcGOU35Rf95725snvzEOy4BFB7+eLJ8djCNPmGwG67w==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.0.12",
"builder-util": "26.0.11",
@@ -8846,7 +8835,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8867,7 +8855,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9118,6 +9105,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -11023,7 +11011,6 @@
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11085,7 +11072,6 @@
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
@@ -11461,6 +11447,12 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@@ -13498,7 +13490,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
@@ -13515,7 +13506,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13533,7 +13523,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13722,6 +13711,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13731,6 +13721,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14080,7 +14071,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -14269,6 +14259,7 @@
"resolved": "https://registry.npmjs.org/seroval/-/seroval-1.4.0.tgz",
"integrity": "sha512-BdrNXdzlofomLTiRnwJTSEAaGKyHHZkbMXIywOh7zlzp4uZnXErEwl9XZ+N1hJSNpeTtNxWvVwN0wUzAIQ4Hpg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
}
@@ -14317,7 +14308,6 @@
"hasInstallScript": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
@@ -14368,7 +14358,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14391,7 +14380,6 @@
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14414,7 +14402,6 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14431,7 +14418,6 @@
"os": [
"darwin"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14448,7 +14434,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14465,7 +14450,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14482,7 +14466,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14499,7 +14482,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14516,7 +14498,6 @@
"os": [
"linux"
],
"peer": true,
"funding": {
"url": "https://opencollective.com/libvips"
}
@@ -14533,7 +14514,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14556,7 +14536,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14579,7 +14558,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14602,7 +14580,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14625,7 +14602,6 @@
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -14648,7 +14624,6 @@
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
@@ -15117,7 +15092,6 @@
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"peer": true,
"dependencies": {
"client-only": "0.0.1"
},
@@ -15287,7 +15261,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15351,7 +15324,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -15449,6 +15421,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15653,6 +15626,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15922,6 +15896,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/utf8-byte-length": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
@@ -16009,6 +15998,7 @@
"integrity": "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16098,7 +16088,8 @@
"resolved": "https://registry.npmjs.org/vite-plugin-electron-renderer/-/vite-plugin-electron-renderer-0.14.6.tgz",
"integrity": "sha512-oqkWFa7kQIkvHXG7+Mnl1RTroA4sP0yesKatmAy0gjZC4VwUqlvF9IvOpHd1fpLWsqYX/eZlVxlhULNtaQ78Jw==",
"dev": true,
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/vite/node_modules/fdir": {
"version": "6.5.0",
@@ -16124,6 +16115,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16166,6 +16158,7 @@
"integrity": "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@vitest/expect": "4.0.16",
"@vitest/mocker": "4.0.16",
@@ -16423,6 +16416,7 @@
"integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
"dev": true,
"license": "ISC",
"peer": true,
"bin": {
"yaml": "bin.mjs"
},