mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-01-30 06:12:03 +00:00
Merge pull request #226 from JBotwina/graph-filtering-and-node-controls
feat: Graph Filtering and Node Controls
This commit is contained in:
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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'
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
110
package-lock.json
generated
@@ -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"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user