From f7bccab7e084d4554d8142b3a2af0bba925f0e40 Mon Sep 17 00:00:00 2001
From: Rares Popa <2606875+rarepops@users.noreply.github.com>
Date: Mon, 24 Aug 2026 19:42:32 +0200
Subject: [PATCH] fix(ui): load every projects page
Follow list_projects pagination metadata so the management UI does not stop at 50 projects. Publish the complete list before schema hydration and cap schema lookups at four concurrent requests.
Signed-off-by: Rares Popa <2606875+rarepops@users.noreply.github.com>
---
graph-ui/src/hooks/useProjects.test.tsx | 105 ++++++++++++++++++++++++
graph-ui/src/hooks/useProjects.ts | 80 ++++++++++++++----
2 files changed, 168 insertions(+), 17 deletions(-)
create mode 100644 graph-ui/src/hooks/useProjects.test.tsx
diff --git a/graph-ui/src/hooks/useProjects.test.tsx b/graph-ui/src/hooks/useProjects.test.tsx
new file mode 100644
index 000000000..1be534763
--- /dev/null
+++ b/graph-ui/src/hooks/useProjects.test.tsx
@@ -0,0 +1,105 @@
+/* @vitest-environment jsdom */
+import "@testing-library/jest-dom/vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { useProjects } from "./useProjects";
+
+const { callToolMock } = vi.hoisted(() => ({ callToolMock: vi.fn() }));
+
+vi.mock("../api/rpc", () => ({ callTool: callToolMock }));
+
+function ProjectsHarness() {
+ const { projects } = useProjects();
+ return ;
+}
+
+describe("useProjects", () => {
+ afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+ });
+
+ it("loads every list_projects page", async () => {
+ const firstPage = Array.from({ length: 50 }, (_, index) => ({
+ name: `project-${index + 1}`,
+ root_path: `/repo/project-${index + 1}`,
+ indexed_at: "2026-01-01T00:00:00Z",
+ }));
+ const finalProject = {
+ name: "project-51",
+ root_path: "/repo/project-51",
+ indexed_at: "2026-01-01T00:00:00Z",
+ };
+
+ callToolMock.mockImplementation(
+ async (name: string, args: Record = {}) => {
+ if (name === "list_projects") {
+ if (args.offset === 50) {
+ return {
+ projects: [finalProject],
+ total: 51,
+ offset: 50,
+ limit: 50,
+ returned: 1,
+ has_more: false,
+ };
+ }
+ return {
+ projects: firstPage,
+ total: 51,
+ offset: 0,
+ limit: 50,
+ returned: 50,
+ has_more: true,
+ };
+ }
+ return {
+ node_labels: [],
+ edge_types: [],
+ total_nodes: 0,
+ total_edges: 0,
+ };
+ },
+ );
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("project-count")).toHaveTextContent("51");
+ });
+ expect(callToolMock).toHaveBeenCalledWith("list_projects", {
+ offset: 50,
+ limit: 50,
+ });
+ });
+
+ it("renders projects before bounded schema hydration completes", async () => {
+ const projects = Array.from({ length: 10 }, (_, index) => ({
+ name: `project-${index + 1}`,
+ root_path: `/repo/project-${index + 1}`,
+ indexed_at: "2026-01-01T00:00:00Z",
+ }));
+
+ callToolMock.mockImplementation(async (name: string) => {
+ if (name === "list_projects") {
+ return {
+ projects,
+ total: projects.length,
+ offset: 0,
+ limit: 50,
+ returned: projects.length,
+ has_more: false,
+ };
+ }
+ return new Promise(() => {});
+ });
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("project-count")).toHaveTextContent("10");
+ });
+ const schemaCalls = callToolMock.mock.calls.filter(([name]) => name === "get_graph_schema");
+ expect(schemaCalls).toHaveLength(4);
+ });
+});
diff --git a/graph-ui/src/hooks/useProjects.ts b/graph-ui/src/hooks/useProjects.ts
index 5deff5215..dcd3777fe 100644
--- a/graph-ui/src/hooks/useProjects.ts
+++ b/graph-ui/src/hooks/useProjects.ts
@@ -14,6 +14,64 @@ interface UseProjectsResult {
refresh: () => void;
}
+interface ProjectPage {
+ projects?: Project[];
+ offset?: number;
+ returned?: number;
+ has_more?: boolean;
+}
+
+const PROJECT_PAGE_SIZE = 50;
+const SCHEMA_CONCURRENCY = 4;
+
+async function fetchAllProjects(): Promise {
+ const projects: Project[] = [];
+ let offset = 0;
+
+ while (true) {
+ const page = await callTool("list_projects", {
+ offset,
+ limit: PROJECT_PAGE_SIZE,
+ });
+ const pageProjects = page.projects ?? [];
+ projects.push(...pageProjects);
+
+ if (!page.has_more) return projects;
+
+ const pageOffset = Number.isSafeInteger(page.offset) ? page.offset! : offset;
+ const returned = Number.isSafeInteger(page.returned) ? page.returned! : pageProjects.length;
+ const nextOffset = pageOffset + returned;
+ if (returned <= 0 || nextOffset <= offset) {
+ throw new Error("list_projects pagination did not advance");
+ }
+ offset = nextOffset;
+ }
+}
+
+async function hydrateSchemas(infos: ProjectInfo[]): Promise {
+ let nextIndex = 0;
+
+ async function worker() {
+ while (nextIndex < infos.length) {
+ const index = nextIndex++;
+ const project = infos[index].project;
+ try {
+ infos[index] = {
+ project,
+ schema: await callTool("get_graph_schema", {
+ project: project.name,
+ }),
+ };
+ } catch {
+ infos[index] = { project, schema: null };
+ }
+ }
+ }
+
+ const workerCount = Math.min(SCHEMA_CONCURRENCY, infos.length);
+ await Promise.all(Array.from({ length: workerCount }, () => worker()));
+}
+
export function useProjects(): UseProjectsResult {
const [projects, setProjects] = useState([]);
const [loading, setLoading] = useState(true);
@@ -23,24 +81,12 @@ export function useProjects(): UseProjectsResult {
setLoading(true);
setError(null);
try {
- const result = await callTool<{ projects: Project[] }>("list_projects");
- const list = result.projects ?? [];
-
- /* Fetch schema for each project */
- const infos: ProjectInfo[] = await Promise.all(
- list.map(async (p) => {
- try {
- const schema = await callTool("get_graph_schema", {
- project: p.name,
- });
- return { project: p, schema };
- } catch {
- return { project: p, schema: null };
- }
- }),
- );
-
+ const list = await fetchAllProjects();
+ const infos: ProjectInfo[] = list.map((project) => ({ project, schema: null }));
setProjects(infos);
+
+ await hydrateSchemas(infos);
+ setProjects([...infos]);
} catch (e) {
setError(e instanceof Error ? e.message : "Failed to fetch projects");
} finally {