From cc5dcbf8e88487c1a29fb5e9291b4e63bb796288 Mon Sep 17 00:00:00 2001 From: cooper Date: Wed, 23 Sep 2026 07:12:35 -0700 Subject: [PATCH] feat(web): project picker uses v1 ProjectService The sidebar project picker listed, validated and opened projects through the tRPC agent router, which fetches http://localhost:9876 from the web server and so cannot reach a user's agent from the Cloudflare-hosted Studio. It now calls the agent's v1 ProjectService from the browser: ListProjects through connect-query, and AddProject, whose InvalidArgument message replaces the separate validate step. Projects the agent reports invalid show the reason and cannot be opened. AgentProvider creates one Connect transport per host, port and token and provides it with connect-query's TransportProvider; useAgentRpcClient reuses it instead of building its own. @stackpanel/proto exports the v1 generated code as @stackpanel/proto/agent/v1/*. Switching and reading the current project stay on REST (/api/project/open and /api/project/current) until requests select their project (stackpanel-thq.8.1). AgentHttpClient now unwraps the agent's { success, data } envelope for both; it returned the envelope itself, which only the demo's bare fixtures matched, so the setup wizard's current-project read was also empty against a real agent. The demo keeps its badge and makes no project requests. With its last callers gone (the picker, the unused ProjectIndicator and the dead SecurityStatusCard), the tRPC agent router is deleted. bun.nix is regenerated with bun2nix, which also drops 26 entries left over from the last bun.lock change. Refs: stackpanel-thq.8.4 --- apps/web/package.json | 1 + .../src/components/project-selector.test.tsx | 128 +++++ apps/web/src/components/project-selector.tsx | 301 ++++------- .../studio/panels/overview-panel.tsx | 3 - .../studio/security-status-card.tsx | 340 ------------- apps/web/src/lib/agent-provider.tsx | 15 +- apps/web/src/lib/agent-setup.test.ts | 36 ++ apps/web/src/lib/agent.ts | 18 +- apps/web/src/lib/connect-transport.ts | 7 +- apps/web/src/lib/use-agent.ts | 14 +- bun.lock | 5 + bun.nix | 112 +---- packages/api/src/routers/agent.ts | 467 ------------------ packages/api/src/routers/index.ts | 2 - packages/proto/package.json | 4 + 15 files changed, 325 insertions(+), 1128 deletions(-) create mode 100644 apps/web/src/components/project-selector.test.tsx delete mode 100644 apps/web/src/components/studio/security-status-card.tsx delete mode 100644 packages/api/src/routers/agent.ts 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 ( <>