diff --git a/.gitignore b/.gitignore
index 5860fb1b..2edd0038 100644
--- a/.gitignore
+++ b/.gitignore
@@ -59,3 +59,6 @@ soak-results/
# LSP originality-check reference cache (scripts/check-lsp-originality.sh)
.lsp-refs/
+
+# Local npm cache
+graph-ui/.npm-cache-local/
diff --git a/graph-ui/src/components/StatsTab.test.tsx b/graph-ui/src/components/StatsTab.test.tsx
new file mode 100644
index 00000000..4b288bfa
--- /dev/null
+++ b/graph-ui/src/components/StatsTab.test.tsx
@@ -0,0 +1,106 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, fireEvent, act } from "@testing-library/react";
+import { IndexProgress } from "./StatsTab";
+import "@testing-library/jest-dom";
+
+describe("IndexProgress", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ vi.restoreAllMocks();
+ });
+
+ it("polls and shows indexing in progress when active", async () => {
+ const fetchMock = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ json: () => Promise.resolve([
+ { slot: 1, status: "indexing", path: "/path/to/project1" }
+ ])
+ } as unknown as Response)
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const onDone = vi.fn();
+ render();
+
+ // Fast-forward initial poll
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+
+ expect(fetchMock).toHaveBeenCalledWith("/api/index-status");
+ expect(screen.getByText("Indexing in progress")).toBeInTheDocument();
+ expect(screen.getByText("/path/to/project1")).toBeInTheDocument();
+ expect(onDone).not.toHaveBeenCalled();
+ });
+
+ it("stops polling and calls onDone when indexing finishes successfully", async () => {
+ let mockData = [
+ { slot: 1, status: "indexing", path: "/path/to/project" }
+ ];
+ const fetchMock = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ json: () => Promise.resolve(mockData)
+ } as unknown as Response)
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const onDone = vi.fn();
+ render();
+
+ // First poll returns active
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+ expect(onDone).not.toHaveBeenCalled();
+
+ // Indexing finishes
+ mockData = [];
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+
+ expect(onDone).toHaveBeenCalled();
+ });
+
+ it("renders error banner and does NOT call onDone when indexing fails with error status", async () => {
+ const fetchMock = vi.fn().mockImplementation(() =>
+ Promise.resolve({
+ json: () => Promise.resolve([
+ { slot: 1, status: "error", path: "/path/to/failed-project", error: "OOM Error" }
+ ])
+ } as unknown as Response)
+ );
+ vi.stubGlobal("fetch", fetchMock);
+
+ const onDone = vi.fn();
+ render();
+
+ await act(async () => {
+ await vi.advanceTimersByTimeAsync(2000);
+ });
+
+ // Error banner should show up
+ expect(screen.getByText("Indexing Failed")).toBeInTheDocument();
+ expect(screen.getByText("/path/to/failed-project")).toBeInTheDocument();
+ expect(screen.getByText("OOM Error")).toBeInTheDocument();
+
+ // onDone should not be called automatically
+ expect(onDone).not.toHaveBeenCalled();
+
+ // Click Dismiss button
+ const dismissBtn = screen.getByRole("button", { name: /Dismiss/i });
+ expect(dismissBtn).toBeInTheDocument();
+
+ await act(async () => {
+ fireEvent.click(dismissBtn);
+ });
+
+ // onDone should be called after manual dismissal
+ expect(onDone).toHaveBeenCalled();
+ });
+});
diff --git a/graph-ui/src/components/StatsTab.tsx b/graph-ui/src/components/StatsTab.tsx
index 98890271..9b8fec10 100644
--- a/graph-ui/src/components/StatsTab.tsx
+++ b/graph-ui/src/components/StatsTab.tsx
@@ -273,20 +273,36 @@ function CreateIndexModal({ onClose, onCreated }: { onClose: () => void; onCreat
/* ── Index Progress ─────────────────────────────────────── */
-function IndexProgress({ onDone }: { onDone: () => void }) {
- const [jobs, setJobs] = useState<{ slot: number; status: string; path: string }[]>([]);
+export function IndexProgress({ onDone }: { onDone: () => void }) {
+ const [jobs, setJobs] = useState<{ slot: number; status: string; path: string; error?: string }[]>([]);
+ const [hasActive, setHasActive] = useState(true);
+
useEffect(() => {
+ if (!hasActive) return;
const poll = setInterval(async () => {
try {
const data = await (await fetch("/api/index-status")).json();
setJobs(data);
- if (data.length > 0 && data.every((j: { status: string }) => j.status !== "indexing")) onDone();
- } catch { /* */ }
+ const stillIndexing = data.some((j: { status: string }) => j.status === "indexing");
+ if (!stillIndexing) {
+ setHasActive(false);
+ const hasErrors = data.some((j: { status: string }) => j.status === "error");
+ if (!hasErrors) {
+ onDone();
+ }
+ }
+ } catch (error) {
+ console.error("[IndexProgress] Poll failed:", error);
+ }
}, 2000);
return () => clearInterval(poll);
- }, [onDone]);
+ }, [onDone, hasActive]);
+
const active = jobs.filter((j) => j.status === "indexing");
- if (active.length === 0) return null;
+ const errors = jobs.filter((j) => j.status === "error");
+
+ if (active.length === 0 && errors.length === 0) return null;
+
return (
{active.map((j) => (
@@ -298,6 +314,26 @@ function IndexProgress({ onDone }: { onDone: () => void }) {
))}
+ {errors.map((j) => (
+
+
⚠️
+
+
Indexing Failed
+
{j.path}
+ {j.error &&
{j.error}
}
+
+
+ ))}
+ {errors.length > 0 && (
+
+
+
+ )}
);
}
diff --git a/graph-ui/vite.config.ts b/graph-ui/vite.config.ts
index 0d02daf8..d7a3a0b8 100644
--- a/graph-ui/vite.config.ts
+++ b/graph-ui/vite.config.ts
@@ -1,3 +1,4 @@
+///
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
@@ -10,6 +11,10 @@ export default defineConfig({
"@": path.resolve(__dirname, "./src"),
},
},
+ test: {
+ environment: "jsdom",
+ globals: true,
+ },
build: {
outDir: "dist",
assetsDir: "assets",