diff --git a/apps/web/package.json b/apps/web/package.json index da726afa..2e0925d3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -38,6 +38,7 @@ "@base-ui/react": "^1.1.0", "@bufbuild/protobuf": "^2.10.2", "@connectrpc/connect": "^2.1.1", + "@connectrpc/connect-query": "^2.3.1", "@connectrpc/connect-web": "^2.1.1", "@distilled.cloud/cloudflare": "catalog:", "@fontsource-variable/google-sans-code": "^5.2.3", diff --git a/apps/web/src/components/project-selector.test.tsx b/apps/web/src/components/project-selector.test.tsx new file mode 100644 index 00000000..448ce7fb --- /dev/null +++ b/apps/web/src/components/project-selector.test.tsx @@ -0,0 +1,128 @@ +import { Code, ConnectError, createRouterTransport } from "@connectrpc/connect"; +import { TransportProvider } from "@connectrpc/connect-query"; +import { ProjectService } from "@stackpanel/proto/agent/v1/project"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ProjectSelector } from "./project-selector"; + +const { agentClient, endpoint } = vi.hoisted(() => ({ + agentClient: { getCurrentProject: vi.fn(), openProject: vi.fn() }, + endpoint: { isDemo: false, useLocal: () => {} }, +})); + +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => () => {} })); +vi.mock("@/lib/agent-endpoint", () => ({ useAgentEndpoint: () => endpoint })); +vi.mock("@/lib/agent-provider", () => ({ + useAgentContext: () => ({ + host: "localhost", + port: 9876, + token: "paired-token", + healthStatus: "available", + isAuthenticated: true, + }), + useAgentClient: () => agentClient, +})); + +const shop = { id: "shop-1", name: "shop", path: "/work/shop", valid: true }; +const api = { id: "api-2", name: "api", path: "/work/api", valid: true }; +const gone = { + id: "gone-3", + name: "gone", + path: "/work/gone", + valid: false, + invalidReason: "project directory does not exist", +}; + +// Serves ProjectService in memory, the way the agent's v1 handler answers. +function renderSelector() { + const calls = { listProjects: 0 }; + const transport = createRouterTransport(({ service }) => { + service(ProjectService, { + listProjects: () => { + calls.listProjects++; + return { projects: [shop, api, gone], defaultProjectId: shop.id }; + }, + addProject: () => { + throw new ConnectError( + "directory is not a git repository (no .git folder)", + Code.InvalidArgument, + ); + }, + }); + }); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + render( + + + + + , + ); + return calls; +} + +async function openPicker() { + const trigger = await screen.findByRole("combobox"); + await waitFor(() => expect(trigger.hasAttribute("data-disabled")).toBe(false)); + fireEvent.click(trigger); + await screen.findAllByRole("option"); +} + +describe("ProjectSelector", () => { + beforeEach(() => { + endpoint.isDemo = false; + agentClient.getCurrentProject.mockResolvedValue({ + has_project: true, + project: { id: shop.id, name: shop.name, path: shop.path }, + }); + agentClient.openProject.mockImplementation(async (path: string) => ({ + success: true, + project: { id: "opened", name: "opened", path }, + })); + }); + + it("lists the registry and disables projects the agent reports invalid", async () => { + renderSelector(); + await openPicker(); + + expect(screen.getByRole("option", { name: /gone/ }).getAttribute("aria-disabled")).toBe("true"); + expect(screen.getByText("project directory does not exist")).toBeTruthy(); + expect(screen.getByRole("option", { name: /api/ }).getAttribute("aria-disabled")).toBeNull(); + }); + + it("switches projects over REST and refreshes the registry", async () => { + const calls = renderSelector(); + await openPicker(); + + fireEvent.click(screen.getByRole("option", { name: /api/ })); + + await waitFor(() => expect(agentClient.openProject).toHaveBeenCalledWith(api.path)); + await waitFor(() => expect(calls.listProjects).toBe(2)); + }); + + it("shows why AddProject rejected the path", async () => { + renderSelector(); + await openPicker(); + + fireEvent.click(screen.getByRole("option", { name: /Add project/ })); + fireEvent.change(await screen.findByLabelText("Project Path"), { + target: { value: "/work/plain-dir" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Add Project" })); + + expect( + await screen.findByText("directory is not a git repository (no .git folder)"), + ).toBeTruthy(); + expect(agentClient.openProject).not.toHaveBeenCalled(); + }); + + it("stays off the network in demo mode", async () => { + endpoint.isDemo = true; + const calls = renderSelector(); + + expect(await screen.findByText("stackpanel-demo")).toBeTruthy(); + expect(calls.listProjects).toBe(0); + expect(agentClient.getCurrentProject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/project-selector.tsx b/apps/web/src/components/project-selector.tsx index 43aeb022..cfdfb04b 100644 --- a/apps/web/src/components/project-selector.tsx +++ b/apps/web/src/components/project-selector.tsx @@ -1,5 +1,13 @@ "use client"; +import { ConnectError } from "@connectrpc/connect"; +import { + createConnectQueryKey, + useMutation as useConnectMutation, + useQuery as useConnectQuery, + useTransport, +} from "@connectrpc/connect-query"; +import { ProjectService } from "@stackpanel/proto/agent/v1/project"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate } from "@tanstack/react-router"; import { Button } from "@ui/button"; @@ -26,17 +34,7 @@ import { Check, FolderOpen, Loader2, LogOut, Plus } from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { useAgentEndpoint } from "@/lib/agent-endpoint"; -import { useAgentContext } from "@/lib/agent-provider"; -import { useTRPC } from "@/utils/trpc"; - -interface Project { - id?: string; - path: string; - name: string; - active?: boolean; - is_default?: boolean; - last_opened?: string; -} +import { useAgentClient, useAgentContext } from "@/lib/agent-provider"; const ADD_PROJECT_VALUE = "__add_project__"; @@ -45,151 +43,112 @@ interface ProjectSelectorProps { } export function ProjectSelector(_props: ProjectSelectorProps) { - const { host, port, token, healthStatus } = useAgentContext(); + const { host, port, token, healthStatus, isAuthenticated } = useAgentContext(); + const agentClient = useAgentClient(); + const transport = useTransport(); const { isDemo, useLocal } = useAgentEndpoint(); const [addDialogOpen, setAddDialogOpen] = useState(false); const [newProjectPath, setNewProjectPath] = useState(""); const [validationError, setValidationError] = useState(null); const navigate = useNavigate(); - - const trpc = useTRPC(); const queryClient = useQueryClient(); - // Query for listing projects (public endpoint). - // Disabled in demo mode — the picker doesn't need a project list because - // there's only one (the synthetic demo project). - const projectsQuery = useQuery( - trpc.agent.listProjects.queryOptions( - { host, port }, - { - enabled: !isDemo && healthStatus === "available", - staleTime: 5000, - }, - ), + // The project registry comes from the agent's v1 ProjectService, called + // from the browser. The demo stays off the network: it has one synthetic + // project and renders a badge instead of the picker. + const projectsQuery = useConnectQuery( + ProjectService.method.listProjects, + {}, + { enabled: isAuthenticated && !isDemo, staleTime: 5000 }, ); + const listProjectsKey = createConnectQueryKey({ + schema: ProjectService.method.listProjects, + transport, + cardinality: "finite", + }); - // Query for current project (public endpoint) - const currentProjectQuery = useQuery( - trpc.agent.currentProject.queryOptions( - { host, port }, - { - enabled: !isDemo && healthStatus === "available", - staleTime: 5000, - }, - ), - ); + // The rest of the Studio still runs against the agent's single current + // project, so reading and switching it stays on REST until requests select + // their project (ADR 0005, stackpanel-thq.8.1). + const currentProjectKey = ["agent", "project", "current", host, port]; + const currentProjectQuery = useQuery({ + queryKey: currentProjectKey, + queryFn: () => agentClient.getCurrentProject(), + enabled: !isDemo && healthStatus === "available", + staleTime: 5000, + }); - // Mutation for opening a project - const openProjectMutation = useMutation( - trpc.agent.openProject.mutationOptions({ - onSuccess: (data) => { - toast.success(`Opened project: ${data.project.name}`); - queryClient.invalidateQueries({ - queryKey: trpc.agent.listProjects.queryKey(), - }); - queryClient.invalidateQueries({ - queryKey: trpc.agent.currentProject.queryKey(), + const openProjectMutation = useMutation({ + mutationFn: (path: string) => agentClient.openProject(path), + onSuccess: ({ project }) => { + toast.success(`Opened project: ${project.name}`); + void queryClient.invalidateQueries({ queryKey: listProjectsKey }); + void queryClient.invalidateQueries({ queryKey: currentProjectKey }); + if (project.id) { + void navigate({ + to: "/studio", + search: { project: project.id }, }); - // Update URL with project ID - const projectId = (data.project as Project).id; - if (projectId) { - navigate({ - to: "/studio", - search: { project: projectId }, - }); - } - }, - onError: (error) => { - toast.error(error.message || "Failed to open project"); - }, - }), - ); + } + }, + onError: (error) => { + toast.error(error.message || "Failed to open project"); + }, + }); - // Mutation for validating a project - const validateProjectMutation = useMutation( - trpc.agent.validateProject.mutationOptions(), - ); + // AddProject validates the directory and fails with InvalidArgument, and a + // message saying why, when it is not a stackpanel project. + const addProjectMutation = useConnectMutation(ProjectService.method.addProject, { + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: listProjectsKey }); + }, + }); - // Cast to Project[] to include id field from updated API - const projects = (projectsQuery.data ?? []).map((p) => ({ - ...p, - id: (p as Project).id, - })) as Project[]; - const currentProject = currentProjectQuery.data?.has_project - ? ({ - ...currentProjectQuery.data.project, - id: (currentProjectQuery.data.project as Project).id, - } as Project) - : null; + const projects = projectsQuery.data?.projects ?? []; + const defaultProjectId = projectsQuery.data?.defaultProjectId; + const currentProject = currentProjectQuery.data?.project ?? null; const isLoading = projectsQuery.isLoading || currentProjectQuery.isLoading; const isOpening = openProjectMutation.isPending; + const isAdding = addProjectMutation.isPending || openProjectMutation.isPending; - const handleSelectProject = async (value: string) => { - if (value === ADD_PROJECT_VALUE) { + const handleSelectProject = (path: string) => { + if (path === ADD_PROJECT_VALUE) { setAddDialogOpen(true); return; } - // Value can be project ID or path - const selectedProject = projects.find( - (p) => p.id === value || p.path === value, - ); + const selectedProject = projects.find((p) => p.path === path); + if (!selectedProject?.valid || selectedProject.path === currentProject?.path) return; - if ( - !selectedProject || - selectedProject.path === currentProject?.path || - !token - ) - return; + openProjectMutation.mutate(selectedProject.path); + }; - openProjectMutation.mutate({ - path: selectedProject.path, - token, - host, - port, - }); + const closeAddDialog = () => { + setAddDialogOpen(false); + setNewProjectPath(""); + setValidationError(null); }; const handleAddProject = async () => { - if (!newProjectPath.trim()) { + const path = newProjectPath.trim(); + if (!path) { setValidationError("Please enter a project path"); return; } - if (!token) { - setValidationError("You must pair with the agent first"); - return; - } - setValidationError(null); try { - // First validate the project - const validateResult = await validateProjectMutation.mutateAsync({ - path: newProjectPath.trim(), - token, - host, - port, - }); - - if (!validateResult.valid) { - setValidationError(validateResult.message ?? "Invalid project path"); - return; - } - - // Now open the project - await openProjectMutation.mutateAsync({ - path: newProjectPath.trim(), - token, - host, - port, - }); - - setAddDialogOpen(false); - setNewProjectPath(""); + const { project } = await addProjectMutation.mutateAsync({ path }); + await openProjectMutation.mutateAsync(project?.path ?? path); + closeAddDialog(); } catch (err) { setValidationError( - err instanceof Error ? err.message : "Failed to add project", + err instanceof ConnectError + ? err.rawMessage + : err instanceof Error + ? err.message + : "Failed to add project", ); } }; @@ -225,7 +184,7 @@ export function ProjectSelector(_props: ProjectSelectorProps) { return ( <>