diff --git a/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx b/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx
index 01d4a77e17..52416d9ef5 100644
--- a/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx
+++ b/src/__tests__/unit/components/graph-explorer/GraphExplorer.test.tsx
@@ -1,5 +1,5 @@
import React from "react";
-import { render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, test, vi } from "vitest";
@@ -1033,6 +1033,160 @@ describe("GraphExplorer", () => {
});
});
+// ── Live search: auto re-run on filter/query change ──────────────────────────
+describe("GraphExplorer live search", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.restoreAllMocks();
+ });
+
+ // ── (a) Toggling a type filter with an existing query auto-re-runs search ──
+ test("toggling a node-type filter with an existing query auto-re-runs search", async () => {
+ const fetchMock = makeRoutedFetch([
+ NODE_TYPES_ROUTE,
+ { match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS },
+ ]);
+ global.fetch = fetchMock;
+
+ render();
+ await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1));
+
+ await userEvent.type(screen.getByTestId("search-input"), "processData");
+ await userEvent.click(screen.getByTestId("search-button"));
+ await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1));
+
+ // Toggling a filter with a query already present should re-fire on its own —
+ // no second click on the Search button.
+ await userEvent.click(screen.getByTestId("node-type-filter-button"));
+ await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
+ await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));
+
+ await waitFor(() => expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(2));
+ expect(urlsFor(fetchMock, "/graph/nodes/search")[1]).toContain(
+ `types=${encodeURIComponent("Concept,Function")}`,
+ );
+ });
+
+ // ── (b) Typing debounces before firing ────────────────────────────────────
+ test("typing in the query box debounces before firing", async () => {
+ vi.useFakeTimers();
+ try {
+ const fetchMock = makeRoutedFetch([
+ NODE_TYPES_ROUTE,
+ { match: "/graph/nodes/search", ok: true, status: 200, body: MOCK_SEARCH_RESULTS },
+ ]);
+ global.fetch = fetchMock;
+
+ render();
+ const input = screen.getByTestId("search-input");
+
+ fireEvent.change(input, { target: { value: "p" } });
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ });
+ fireEvent.change(input, { target: { value: "pr" } });
+ await act(async () => {
+ vi.advanceTimersByTime(100);
+ });
+ fireEvent.change(input, { target: { value: "pro" } });
+
+ // Still inside the quiet window — no request fired yet.
+ expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0);
+
+ await act(async () => {
+ vi.advanceTimersByTime(300);
+ });
+
+ // Only the settled value fires, and only once.
+ expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(1);
+ expect(urlsFor(fetchMock, "/graph/nodes/search")[0]).toContain("q=pro");
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ // ── (c) Empty query doesn't fetch on filter change ────────────────────────
+ test("an empty query does not trigger a fetch when toggling a filter", async () => {
+ const fetchMock = makeRoutedFetch([NODE_TYPES_ROUTE]);
+ global.fetch = fetchMock;
+
+ render();
+ await waitFor(() => expect(urlsFor(fetchMock, "/graph/node-types")).toHaveLength(1));
+
+ await userEvent.click(screen.getByTestId("node-type-filter-button"));
+ await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
+ await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));
+ await userEvent.keyboard("{Escape}");
+
+ // No query text was ever entered — the filter click must not fetch.
+ expect(urlsFor(fetchMock, "/graph/nodes/search")).toHaveLength(0);
+ });
+
+ // ── (d) Stale/out-of-order responses don't clobber newer results ─────────
+ test("a slow, superseded search response does not overwrite a newer one", async () => {
+ const respond = (body: unknown) => ({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve(body),
+ text: () => Promise.resolve(""),
+ });
+
+ let releaseFirst: (() => void) | undefined;
+ let searchCallCount = 0;
+
+ global.fetch = vi.fn().mockImplementation((url: string) => {
+ if (url.includes("/graph/node-types")) return Promise.resolve(respond(MOCK_NODE_TYPES));
+ if (url.includes("/graph/nodes/search")) {
+ searchCallCount += 1;
+ if (searchCallCount === 1) {
+ // The first (stale) request hangs until explicitly released.
+ return new Promise((resolve) => {
+ releaseFirst = () =>
+ resolve(
+ respond({
+ results: [
+ { ref_id: "stale-1", node_type: "Concept", name: "StaleResult", description: "" },
+ ],
+ }),
+ );
+ });
+ }
+ // Every later request resolves immediately with fresh results.
+ return Promise.resolve(
+ respond({
+ results: [
+ { ref_id: "fresh-1", node_type: "Concept", name: "FreshResult", description: "" },
+ ],
+ }),
+ );
+ }
+ return Promise.resolve(respond({}));
+ });
+
+ render();
+ await userEvent.type(screen.getByTestId("search-input"), "processData");
+ await userEvent.click(screen.getByTestId("search-button"));
+ await waitFor(() => expect(searchCallCount).toBe(1));
+
+ // Toggling a filter while the first request is still in flight fires a
+ // second, faster request that should win.
+ await userEvent.click(screen.getByTestId("node-type-filter-button"));
+ await waitFor(() => screen.getByTestId("node-type-filter-option-Function"));
+ await userEvent.click(screen.getByTestId("node-type-filter-option-Function"));
+
+ await waitFor(() => expect(screen.getByText("FreshResult")).toBeInTheDocument());
+
+ // Now let the stale first request resolve — it must not clobber the fresh
+ // results that are already on screen.
+ releaseFirst?.();
+ await waitFor(() => expect(searchCallCount).toBeGreaterThanOrEqual(2));
+ await new Promise((r) => setTimeout(r, 0));
+
+ expect(screen.getByText("FreshResult")).toBeInTheDocument();
+ expect(screen.queryByText("StaleResult")).not.toBeInTheDocument();
+ });
+});
+
// ── Legal recursion mock branch ───────────────────────────────────────────────
describe("Legal recursion Cypher mock branch", () => {
beforeEach(() => {
diff --git a/src/components/graph-explorer/GraphExplorer.tsx b/src/components/graph-explorer/GraphExplorer.tsx
index 9d01d1603c..ee5a7f1d7e 100644
--- a/src/components/graph-explorer/GraphExplorer.tsx
+++ b/src/components/graph-explorer/GraphExplorer.tsx
@@ -662,40 +662,96 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr
}, []);
// ── Semantic search (Jarvis hybrid keyword + vector) ──────────────────────
+ /** Guards against a slow search response overwriting a newer one. */
+ const searchRequestRef = useRef(0);
+
const runSearch = useCallback(async () => {
- if (!searchQuery.trim()) return;
+ const q = searchQuery.trim();
+ if (!q) return;
+ const requestId = ++searchRequestRef.current;
setSearchLoading(true);
setSearchError(null);
setSearchResults([]);
setSearched(true);
try {
- const params = new URLSearchParams({ q: searchQuery.trim(), limit: "25" });
+ const params = new URLSearchParams({ q, limit: "25" });
if (selectedTypes.length > 0) params.set("types", selectedTypes.join(","));
const res = await fetch(
`/api/workspaces/${workspaceSlug}/graph/nodes/search?${params.toString()}`
);
+ // A newer search superseded this one while it was in flight — drop it.
+ if (requestId !== searchRequestRef.current) return;
+
if (!res.ok) {
const data = await res.json().catch(() => ({}));
+ if (requestId !== searchRequestRef.current) return;
setSearchError((data as { message?: string }).message || `Search failed (${res.status})`);
return;
}
const data: GraphSearchResponse = await res.json();
+ if (requestId !== searchRequestRef.current) return;
setSearchResults(Array.isArray(data?.results) ? data.results : []);
} catch (err) {
+ if (requestId !== searchRequestRef.current) return;
setSearchError(err instanceof Error ? err.message : "Search failed");
} finally {
- setSearchLoading(false);
+ if (requestId === searchRequestRef.current) setSearchLoading(false);
}
}, [searchQuery, selectedTypes, workspaceSlug]);
+ /** Pending debounced auto-search timer, cleared before any direct trigger. */
+ const searchDebounceRef = useRef | null>(null);
+ /** Previous `selectedTypes` reference, used to tell a filter click apart from typing. */
+ const prevSelectedTypesRef = useRef(selectedTypes);
+
+ const clearPendingSearch = useCallback(() => {
+ if (searchDebounceRef.current) {
+ clearTimeout(searchDebounceRef.current);
+ searchDebounceRef.current = null;
+ }
+ }, []);
+
+ /** Manual trigger (Search button / Enter): cancels any pending debounce so it can't double-fire. */
+ const triggerSearch = useCallback(() => {
+ clearPendingSearch();
+ void runSearch();
+ }, [clearPendingSearch, runSearch]);
+
+ /**
+ * Live search: re-runs automatically whenever the type filter or the query
+ * text changes, as long as there's a query to search for. A filter toggle is
+ * a discrete click, so it re-runs immediately; free-typing is debounced so
+ * we don't fire a request per keystroke.
+ */
+ useEffect(() => {
+ const typesChanged = prevSelectedTypesRef.current !== selectedTypes;
+ prevSelectedTypesRef.current = selectedTypes;
+
+ clearPendingSearch();
+
+ if (!searchQuery.trim()) return;
+
+ if (typesChanged) {
+ void runSearch();
+ return;
+ }
+
+ searchDebounceRef.current = setTimeout(() => {
+ searchDebounceRef.current = null;
+ void runSearch();
+ }, 300);
+
+ return () => clearPendingSearch();
+ }, [selectedTypes, searchQuery, runSearch, clearPendingSearch]);
+
const typeFilterLabel = typeFilterLabelFor(selectedTypes);
const handleSearchKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
- runSearch();
+ triggerSearch();
}
};
@@ -774,7 +830,7 @@ export function GraphExplorer({ workspaceSlug, initialRefId, initialCypher }: Gr