diff --git a/apps/ui/package.json b/apps/ui/package.json index 9c3522c6..410e63c1 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -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": { diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 0038b6d3..0459e68b 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -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} /> )} diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx index ad0ba3bd..cbf3cdb9 100644 --- a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -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, }} /> diff --git a/apps/ui/src/components/views/graph-view/components/graph-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-controls.tsx index c2849a33..3c4e60b4 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-controls.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-controls.tsx @@ -31,7 +31,7 @@ export function GraphControls({ return ( -
+
{/* Zoom controls */} @@ -120,22 +120,13 @@ export function GraphControls({ - - {isLocked ? 'Unlock Nodes' : 'Lock Nodes'} - + {isLocked ? 'Unlock Nodes' : 'Lock Nodes'}
diff --git a/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx new file mode 100644 index 00000000..f1564e81 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx @@ -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 ( + + +
+ {/* Category Filter Dropdown */} + + + + + + + + Filter by Category + + +
+
+ Categories +
+ + {/* Select All option */} +
+ 0 + } + onCheckedChange={handleSelectAllCategories} + /> + + {selectedCategories.length === availableCategories.length + ? 'Deselect All' + : 'Select All'} + +
+ +
+ + {/* Category list */} +
+ {availableCategories.length === 0 ? ( +
+ No categories available +
+ ) : ( + availableCategories.map((category) => ( +
handleCategoryToggle(category)} + > + handleCategoryToggle(category)} + /> + {category} +
+ )) + )} +
+
+ + + + {/* Status Filter Dropdown */} + + + + + + + + Filter by Status + + +
+
Status
+ + {/* Select All option */} +
+ + + {selectedStatuses.length === STATUS_FILTER_OPTIONS.length + ? 'Deselect All' + : 'Select All'} + +
+ +
+ + {/* Status list */} +
+ {STATUS_FILTER_OPTIONS.map((status) => { + const config = statusDisplayConfig[status]; + const StatusIcon = config.icon; + return ( +
handleStatusToggle(status)} + > + handleStatusToggle(status)} + /> + + {config.label} +
+ ); + })} +
+
+ + + + {/* Divider */} +
+ + {/* Positive/Negative Filter Toggle */} + + +
+ + +
+
+ + {isNegativeFilter + ? 'Negative filter: Highlighting non-matching nodes' + : 'Positive filter: Highlighting matching nodes'} + +
+ + {/* Clear Filters Button - only show when filters are active */} + {hasActiveFilter && ( + <> +
+ + + + + Clear All Filters + + + )} +
+ + + ); +} diff --git a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx index 93a3a497..f7c9cb55 100644 --- a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx +++ b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx @@ -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 ( -
+
{legendItems.map((item) => { const Icon = item.icon; return ( diff --git a/apps/ui/src/components/views/graph-view/components/index.ts b/apps/ui/src/components/views/graph-view/components/index.ts index eb509a97..1732f3b6 100644 --- a/apps/ui/src/components/views/graph-view/components/index.ts +++ b/apps/ui/src/components/views/graph-view/components/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx index ebc8dcc2..06beb251 100644 --- a/apps/ui/src/components/views/graph-view/components/task-node.tsx +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -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' )} />
{/* Header with status and actions */} -
+
- - {config.label} - + {config.label}
{/* Priority badge */} {priorityConf && ( - + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} )} @@ -158,39 +171,101 @@ export const TaskNode = memo(function TaskNode({ )} + {/* Stopped indicator - task is in_progress but not actively running */} + {isStopped && ( + + + +
+ +
+
+ +

Task paused - click menu to resume

+
+
+
+ )} + {/* Actions dropdown */} - - + e.stopPropagation()} + > + { + e.stopPropagation(); + data.onViewLogs?.(); + }} + > + + View Agent Logs + + { + e.stopPropagation(); + data.onViewDetails?.(); + }} + > View Details {data.status === 'backlog' && !data.isBlocked && ( - + { + e.stopPropagation(); + data.onStartTask?.(); + }} + > Start Task )} {data.isRunning && ( - + { + e.stopPropagation(); + data.onStopTask?.(); + }} + > Stop Task )} - - - - View Branch - + {isStopped && ( + { + e.stopPropagation(); + data.onResumeTask?.(); + }} + > + + Resume Task + + )}
@@ -212,14 +287,22 @@ export const TaskNode = memo(function TaskNode({ {data.isRunning && (
-
+
Running...
)} + {/* Paused indicator for stopped tasks */} + {isStopped && ( +
+
+
+
+ Paused +
+ )} + {/* Branch name if assigned */} {data.branchName && (
@@ -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' )} /> diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx index 6251017d..283de400 100644 --- a/apps/ui/src/components/views/graph-view/graph-canvas.tsx +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -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([]); + const [selectedStatuses, setSelectedStatuses] = useState([]); + 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) => { - 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} /> + + + + {/* Empty state when all nodes are filtered out */} + {filterResult.hasActiveFilter && filterResult.matchedNodeIds.size === 0 && ( + +
+ +
+

No matching tasks

+

+ Try adjusting your filters or search query +

+
+ +
+
+ )}
); diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx index f745f37d..9ef01bce 100644 --- a/apps/ui/src/components/views/graph-view/graph-view.tsx +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -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 (
diff --git a/apps/ui/src/components/views/graph-view/hooks/index.ts b/apps/ui/src/components/views/graph-view/hooks/index.ts index 48efda07..579f583e 100644 --- a/apps/ui/src/components/views/graph-view/hooks/index.ts +++ b/apps/ui/src/components/views/graph-view/hooks/index.ts @@ -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'; diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts new file mode 100644 index 00000000..615f9063 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-filter.ts @@ -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; + highlightedNodeIds: Set; + highlightedEdgeIds: Set; + availableCategories: string[]; + hasActiveFilter: boolean; +} + +/** + * Traverses up the dependency tree to find all ancestors of a node + */ +function getAncestors( + featureId: string, + featureMap: Map, + visited: Set +): 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): 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, features: Feature[]): Set { + const edges = new Set(); + + 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(), + highlightedNodeIds: new Set(), + highlightedEdgeIds: new Set(), + availableCategories, + hasActiveFilter: false, + }; + } + + // Find directly matched nodes + const matchedNodeIds = new Set(); + 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; + 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(); + + 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, + ]); +} diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts index cb1e038e..2b83a4a4 100644 --- a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -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; -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(); + const highlightedNodeIds = filterResult?.highlightedNodeIds ?? new Set(); + const highlightedEdgeIds = filterResult?.highlightedEdgeIds ?? new Set(); + // 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 }; } diff --git a/apps/ui/src/components/views/graph-view/index.ts b/apps/ui/src/components/views/graph-view/index.ts index bcbfdcc0..1cf551d5 100644 --- a/apps/ui/src/components/views/graph-view/index.ts +++ b/apps/ui/src/components/views/graph-view/index.ts @@ -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'; diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 351007db..cd7f8145 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -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; } } diff --git a/package-lock.json b/package-lock.json index 56c88d73..2072c523 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" },