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/graph-view/components/graph-filter-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-filter-controls.tsx index 4fb015d4..f1564e81 100644 --- 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 @@ -43,7 +43,6 @@ interface GraphFilterControlsProps { filterState: GraphFilterState; availableCategories: string[]; hasActiveFilter: boolean; - onSearchQueryChange: (query: string) => void; onCategoriesChange: (categories: string[]) => void; onStatusesChange: (statuses: string[]) => void; onNegativeFilterChange: (isNegative: boolean) => void; @@ -54,7 +53,6 @@ export function GraphFilterControls({ filterState, availableCategories, hasActiveFilter, - onSearchQueryChange, onCategoriesChange, onStatusesChange, onNegativeFilterChange, @@ -62,9 +60,6 @@ export function GraphFilterControls({ }: GraphFilterControlsProps) { const { selectedCategories, selectedStatuses, isNegativeFilter } = filterState; - // Suppress unused variable warning - onSearchQueryChange is used by parent for search input - void onSearchQueryChange; - const handleCategoryToggle = (category: string) => { if (selectedCategories.includes(category)) { onCategoriesChange(selectedCategories.filter((c) => c !== category)); @@ -267,6 +262,12 @@ export function GraphFilterControls({
@@ -311,6 +313,7 @@ export function GraphFilterControls({ size="sm" className="h-8 w-8 p-0 text-muted-foreground hover:text-destructive" onClick={onClearFilters} + aria-label="Clear all filters" > 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 1558bfe9..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, @@ -30,6 +31,9 @@ import { 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 @@ -71,9 +75,12 @@ function GraphCanvasInner({ 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, + searchQuery: debouncedSearchQuery, selectedCategories, selectedStatuses, isNegativeFilter, @@ -196,7 +203,6 @@ function GraphCanvasInner({ filterState={filterState} availableCategories={filterResult.availableCategories} hasActiveFilter={filterResult.hasActiveFilter} - onSearchQueryChange={onSearchQueryChange} onCategoriesChange={setSelectedCategories} onStatusesChange={setSelectedStatuses} onNegativeFilterChange={setIsNegativeFilter} @@ -204,6 +210,24 @@ function GraphCanvasInner({ /> + + {/* 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/package-lock.json b/package-lock.json index 54b2beb3..cd1a62a6 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": { @@ -1215,7 +1216,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -11446,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", @@ -15889,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",