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",