diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 771202053b4..27e8ef5fce6 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -595,6 +595,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/ui/src/components/EmptyState.tsx b/ui/src/components/EmptyState.tsx index 972b8dbce6c..569c0f00ff5 100644 --- a/ui/src/components/EmptyState.tsx +++ b/ui/src/components/EmptyState.tsx @@ -1,5 +1,6 @@ import { Plus } from "lucide-react"; import type { LucideIcon } from "lucide-react"; +import type { Ref } from "react"; import { Button } from "@/components/ui/button"; interface EmptyStateProps { @@ -11,6 +12,7 @@ interface EmptyStateProps { description?: string; action?: string; onAction?: () => void; + actionRef?: Ref; /** Hide the leading "+" glyph on the action button (e.g. for a "Set up" CTA). */ hideActionIcon?: boolean; } @@ -22,6 +24,7 @@ export function EmptyState({ description, action, onAction, + actionRef, hideActionIcon = false, }: EmptyStateProps) { return ( @@ -41,7 +44,7 @@ export function EmptyState({ )} {action && onAction && ( - diff --git a/ui/src/components/RepositoryPicker.test.tsx b/ui/src/components/RepositoryPicker.test.tsx index 7f71dd80d20..b3761f357cd 100644 --- a/ui/src/components/RepositoryPicker.test.tsx +++ b/ui/src/components/RepositoryPicker.test.tsx @@ -89,7 +89,7 @@ describe("RepositoryPicker", () => { ]); const onChange = await render(["repo-app"]); expect(container.textContent).toContain("1 project"); - const docs = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("github.com/acme/docs")); + const docs = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("acme/docs")); await act(() => docs!.click()); expect(onChange).toHaveBeenCalledWith(["repo-app", "repo-docs"]); }); diff --git a/ui/src/components/RepositoryPicker.tsx b/ui/src/components/RepositoryPicker.tsx index 20f4b0a0f09..e9e2f6e55ef 100644 --- a/ui/src/components/RepositoryPicker.tsx +++ b/ui/src/components/RepositoryPicker.tsx @@ -13,6 +13,10 @@ function repositoryLabel(repository: Pick) { + return `${repository.owner}/${repository.name}`; +} + export function RepositoryPicker({ companyId, value, @@ -74,9 +78,12 @@ export function RepositoryPicker({
{selected.map((repository) => (
-
+
- {repositoryLabel(repository)} + + {repositoryName(repository)} + {repository.host} + {repository.state !== "active" ? ( {repository.state} ) : null} @@ -147,7 +154,10 @@ export function RepositoryPicker({ {checked ? : null} - {repositoryLabel(repository)} + + {repositoryName(repository)} + {repository.host} + {repository.projectCount > 0 ? ( {repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"} diff --git a/ui/src/components/repositories/GitHubConnectionDialog.test.tsx b/ui/src/components/repositories/GitHubConnectionDialog.test.tsx index 76569646d60..1661269a15e 100644 --- a/ui/src/components/repositories/GitHubConnectionDialog.test.tsx +++ b/ui/src/components/repositories/GitHubConnectionDialog.test.tsx @@ -156,7 +156,6 @@ describe("GitHubConnectionDialog", () => { repositoriesApiMock.discoverConnectionRepositories.mockResolvedValue(discoveryPage({ items: [], total: 0 })); setInputValue(document.querySelector('input[aria-label="Search repositories"]')!, "zzz"); - clickByText("Search"); await waitFor(() => { expect(repositoriesApiMock.discoverConnectionRepositories).toHaveBeenCalledWith("conn-1", { query: "zzz", diff --git a/ui/src/components/repositories/GitHubConnectionDialog.tsx b/ui/src/components/repositories/GitHubConnectionDialog.tsx index d5254c216eb..86e4ffe1d32 100644 --- a/ui/src/components/repositories/GitHubConnectionDialog.tsx +++ b/ui/src/components/repositories/GitHubConnectionDialog.tsx @@ -1,11 +1,11 @@ -import { useState } from "react"; +import { useDeferredValue, useEffect, useRef, useState } from "react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import type { RepositoryConnection, RepositoryDiscoveryItem, RepositoryDiscoveryPage, } from "@paperclipai/shared"; -import { AlertCircle, ExternalLink, Github, Loader2 } from "lucide-react"; +import { AlertCircle, ExternalLink, Github, Loader2, X } from "lucide-react"; import { repositoriesApi } from "../../api/repositories"; import { queryKeys } from "../../lib/queryKeys"; import { Button } from "@/components/ui/button"; @@ -63,6 +63,8 @@ export function GitHubConnectionDialog({ const [items, setItems] = useState([]); const [nextCursor, setNextCursor] = useState(null); const [search, setSearch] = useState(""); + const deferredSearch = useDeferredValue(search); + const lastSearchRef = useRef(""); const [selected, setSelected] = useState>(new Set()); const [importedCount, setImportedCount] = useState(0); const [skippedCount, setSkippedCount] = useState(0); @@ -75,6 +77,7 @@ export function GitHubConnectionDialog({ setItems([]); setNextCursor(null); setSearch(""); + lastSearchRef.current = ""; setSelected(new Set()); setImportedCount(0); setSkippedCount(0); @@ -124,6 +127,7 @@ export function GitHubConnectionDialog({ onSuccess: async (result) => { setConnection(result.connection); setStep("select"); + lastSearchRef.current = ""; queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(companyId) }); await discoverMutation.mutateAsync({ connectionId: result.connection.id, cursor: null, query: "" }); }, @@ -151,11 +155,14 @@ export function GitHubConnectionDialog({ }); }; - const runSearch = () => { - if (!connection) return; + useEffect(() => { + if (!open || step !== "select" || !connection) return; + const query = deferredSearch.trim(); + if (query === lastSearchRef.current) return; + lastSearchRef.current = query; setSelected(new Set()); - discoverMutation.mutate({ connectionId: connection.id, cursor: null, query: search }); - }; + discoverMutation.mutate({ connectionId: connection.id, cursor: null, query }); + }, [connection, deferredSearch, open, step]); return ( @@ -220,19 +227,26 @@ export function GitHubConnectionDialog({ {step === "select" ? (
-
+
setSearch(event.target.value)} - onKeyDown={(event) => { - if (event.key === "Enter") runSearch(); - }} placeholder="Search repositories" + className={search ? "pr-8" : undefined} /> - + {search ? ( + + ) : null}
{discoverMutation.isError ? ( @@ -272,7 +286,11 @@ export function GitHubConnectionDialog({ type="button" variant="ghost" onClick={() => - connection && discoverMutation.mutate({ connectionId: connection.id, cursor: nextCursor, query: search }) + connection && discoverMutation.mutate({ + connectionId: connection.id, + cursor: nextCursor, + query: lastSearchRef.current, + }) } disabled={discoverMutation.isPending} > diff --git a/ui/src/pages/Repositories.test.tsx b/ui/src/pages/Repositories.test.tsx index 40d5ec93037..82cffb9ab5f 100644 --- a/ui/src/pages/Repositories.test.tsx +++ b/ui/src/pages/Repositories.test.tsx @@ -6,6 +6,7 @@ import { MemoryRouter } from "react-router-dom"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Repositories } from "./Repositories"; +import { ApiError } from "../api/client"; const companyState = vi.hoisted(() => ({ selectedCompanyId: "company-1" })); const breadcrumbState = vi.hoisted(() => ({ setBreadcrumbs: vi.fn() })); @@ -121,12 +122,14 @@ describe("Repositories catalog", () => { container.remove(); }); - it("shows an empty state with a connect action", async () => { + it("keeps manual entry usable when no repository provider is configured", async () => { repositoriesApiMock.list.mockResolvedValue([]); + repositoriesApiMock.listProviders.mockResolvedValue([]); render(); await waitFor(() => { expect(document.body.textContent).toContain("No repositories yet"); - expect(document.body.textContent).toContain("Connect GitHub"); + expect(document.body.textContent).toContain("Add manually"); + expect(document.body.textContent).not.toContain("Connect GitHub"); }); }); @@ -189,6 +192,72 @@ describe("Repositories catalog", () => { cloneUrl: "https://github.com/acme/manual.git", visibility: "unknown", }); + expect(document.body.textContent).toContain("Added acme/manual to the catalog"); + expect((document.activeElement as HTMLButtonElement | null)?.textContent).toContain("Add manually"); + }); + }); + + it("reports an idempotent manual add instead of silently closing", async () => { + repositoriesApiMock.list.mockResolvedValue([makeRepo()]); + repositoriesApiMock.createManual.mockResolvedValue(makeRepo()); + render(); + await waitFor(() => expect(document.body.textContent).toContain("acme/alpha")); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add manually"))!.click(); + await waitFor(() => expect(document.body.textContent).toContain("Add a repository manually")); + const urlInput = document.querySelector("#manual-clone-url")!; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(urlInput, "https://github.com/acme/alpha.git"); + urlInput.dispatchEvent(new Event("input", { bubbles: true })); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add repository"))!.click(); + + await waitFor(() => expect(document.body.textContent).toContain("acme/alpha is already in the catalog")); + }); + + it("shows a specific validation error and marks the manual URL invalid", async () => { + repositoriesApiMock.list.mockResolvedValue([]); + repositoriesApiMock.createManual.mockRejectedValue(new ApiError("Validation error", 422, { + error: "Validation error", + details: [{ path: ["cloneUrl"], message: "Clone URL must use HTTPS or SSH" }], + })); + render(); + await waitFor(() => expect(document.body.textContent).toContain("No repositories yet")); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add manually"))!.click(); + await waitFor(() => expect(document.body.textContent).toContain("Add a repository manually")); + const urlInput = document.querySelector("#manual-clone-url")!; + const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!; + setter.call(urlInput, "file:///local/repo"); + urlInput.dispatchEvent(new Event("input", { bubbles: true })); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Add repository"))!.click(); + + await waitFor(() => { + expect(document.body.textContent).toContain("Clone URL must use HTTPS or SSH"); + expect(urlInput.getAttribute("aria-invalid")).toBe("true"); + expect(urlInput.getAttribute("aria-describedby")).toBe("manual-clone-url-error"); + }); + }); + + it("surfaces a failed sync and refreshes connection health", async () => { + const connection = { + id: "connection-1", + provider: "github", + host: "github.com", + accountName: "acme", + status: "active", + syncStatus: "idle", + syncError: null, + }; + repositoriesApiMock.list.mockResolvedValue([makeRepo()]); + repositoriesApiMock.listConnections + .mockResolvedValueOnce([connection]) + .mockResolvedValue([{ ...connection, status: "error", syncStatus: "failed", syncError: "Provider unavailable" }]); + repositoriesApiMock.syncConnection.mockRejectedValue(new Error("request failed")); + render(); + await waitFor(() => expect(document.body.textContent).toContain("Idle")); + [...document.querySelectorAll("button")].find((button) => button.textContent?.includes("Sync"))!.click(); + + await waitFor(() => { + expect(repositoriesApiMock.listConnections.mock.calls.length).toBeGreaterThan(1); + expect(document.body.textContent).toContain("Sync failed"); }); }); diff --git a/ui/src/pages/Repositories.tsx b/ui/src/pages/Repositories.tsx index 03d6ffbdcf5..06c4651699e 100644 --- a/ui/src/pages/Repositories.tsx +++ b/ui/src/pages/Repositories.tsx @@ -1,9 +1,10 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Link } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { RepositoryCatalogItem, RepositoryConnection } from "@paperclipai/shared"; import { AlertCircle, GitBranch, Github, Loader2, Plus, RefreshCw } from "lucide-react"; import { repositoriesApi } from "../api/repositories"; +import { ApiError } from "../api/client"; import { useCompany } from "../context/CompanyContext"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; @@ -52,6 +53,24 @@ function toneClass(tone: "success" | "warning" | "danger" | "neutral"): string { } } +function apiErrorMessage(error: unknown, fallback: string): string { + if (error instanceof ApiError && error.body && typeof error.body === "object") { + const details = (error.body as { details?: unknown }).details; + if (Array.isArray(details)) { + const message = details.find( + (detail): detail is { message: string } => + Boolean(detail && typeof detail === "object" && typeof (detail as { message?: unknown }).message === "string"), + )?.message; + if (message) return message; + } + } + if (error && typeof error === "object" && "message" in error) { + const message = (error as { message?: unknown }).message; + if (typeof message === "string" && message && message !== "Validation error") return message; + } + return fallback; +} + export function Repositories() { const { selectedCompanyId } = useCompany(); const { setBreadcrumbs } = useBreadcrumbs(); @@ -61,6 +80,9 @@ export function Repositories() { const [githubHost, setGithubHost] = useState(null); const [manualOpen, setManualOpen] = useState(false); const [manualUrl, setManualUrl] = useState(""); + const [catalogNotice, setCatalogNotice] = useState(null); + const githubTriggerRef = useRef(null); + const manualTriggerRef = useRef(null); useEffect(() => { setBreadcrumbs([{ label: "Repositories" }]); @@ -88,26 +110,53 @@ export function Repositories() { const githubProviders = (providersQuery.data ?? []).filter((entry) => entry.provider === "github"); const defaultGithubHost = githubProviders.length === 1 ? githubProviders[0]!.host : null; - function openGithubDialog(host: string | null) { + function restoreTrigger(ref: React.RefObject) { + queueMicrotask(() => ref.current?.focus()); + } + + function openGithubDialog(host: string | null, trigger: HTMLButtonElement) { + githubTriggerRef.current = trigger; setGithubHost(host); setGithubOpen(true); } + function handleGithubOpenChange(next: boolean) { + setGithubOpen(next); + if (!next) restoreTrigger(githubTriggerRef); + } + + function openManualDialog(trigger: HTMLButtonElement) { + manualTriggerRef.current = trigger; + setManualOpen(true); + } + + function handleManualOpenChange(next: boolean) { + setManualOpen(next); + if (!next) restoreTrigger(manualTriggerRef); + } + const manualMutation = useMutation({ mutationFn: (cloneUrl: string) => repositoriesApi.createManual(selectedCompanyId!, { cloneUrl, visibility: "unknown" }), - onSuccess: () => { + onSuccess: (repository) => { + const alreadyPresent = repositories.some((entry) => entry.id === repository.id); + setCatalogNotice(alreadyPresent + ? `${repository.owner}/${repository.name} is already in the catalog.` + : `Added ${repository.owner}/${repository.name} to the catalog.`); setManualOpen(false); setManualUrl(""); queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) }); + restoreTrigger(manualTriggerRef); }, }); const syncMutation = useMutation({ mutationFn: (connectionId: string) => repositoriesApi.syncConnection(connectionId), - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) }); - queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) }); + onSettled: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: queryKeys.repositories.list(selectedCompanyId!) }), + queryClient.invalidateQueries({ queryKey: queryKeys.repositories.connections(selectedCompanyId!) }), + ]); }, }); @@ -145,28 +194,28 @@ export function Repositories() { } const actions = ( -
- {githubProviders.length > 1 ? ( githubProviders.map((entry) => ( - )) - ) : ( - - )} + ) : null}
); return (
-
-
+
+

Repositories

Reusable git repositories available to projects and agents in this company. @@ -182,16 +231,23 @@ export function Repositories() { {connections.map((connection) => { const tone = connectionHealthTone(connection); return ( - -

+ +
-
-

+

+

{connection.accountName ?? connection.host}

-

- {connectionHealthLabel(connection)} - {connection.syncError ? ` — ${connection.syncError}` : ""} +

+ {syncMutation.isError && syncMutation.variables === connection.id + ? "Sync failed — status refreshed; try again" + : connectionHealthLabel(connection)} + {connection.syncError && !(syncMutation.isError && syncMutation.variables === connection.id) + ? ` — ${connection.syncError}` + : ""}

@@ -201,7 +257,7 @@ export function Repositories() { onClick={() => syncMutation.mutate(connection.id)} disabled={connection.status === "disconnected" || syncMutation.isPending} > - {syncMutation.isPending ? ( + {syncMutation.isPending && syncMutation.variables === connection.id ? ( ) : ( @@ -215,14 +271,19 @@ export function Repositories() {
) : null} + {catalogNotice ?

{catalogNotice}

: null} + {repositories.length === 0 ? ( 0 + ? "Add a repository by clone URL, or connect GitHub to import from a provider." + : "Add a repository by clone URL. Provider connections appear here when configured."} + action="Add manually" + actionRef={manualTriggerRef} hideActionIcon - onAction={() => openGithubDialog(defaultGithubHost)} + onAction={() => setManualOpen(true)} /> ) : (
@@ -243,7 +304,7 @@ export function Repositories() {
  • @@ -254,7 +315,7 @@ export function Repositories() {

    {repo.host}

  • -
    +
    {repo.state !== "active" ? ( {repo.state} ) : null} @@ -278,11 +339,11 @@ export function Repositories() { companyId={selectedCompanyId!} open={githubOpen} host={githubHost} - onOpenChange={setGithubOpen} + onOpenChange={handleGithubOpenChange} onImported={() => refresh()} /> - + Add a repository manually @@ -294,14 +355,19 @@ export function Repositories() { setManualUrl(event.target.value)} + onChange={(event) => { + setManualUrl(event.target.value); + if (manualMutation.isError) manualMutation.reset(); + }} placeholder="https://github.com/owner/repo.git" /> {manualMutation.isError ? ( -

    +

    ) : null}
    diff --git a/ui/src/pages/RepositoryDetail.test.tsx b/ui/src/pages/RepositoryDetail.test.tsx index 479e79f4a03..46963a9903b 100644 --- a/ui/src/pages/RepositoryDetail.test.tsx +++ b/ui/src/pages/RepositoryDetail.test.tsx @@ -88,8 +88,9 @@ describe("RepositoryDetail", () => { await act(() => new Promise((resolve) => setTimeout(resolve, 0))); expect(container.textContent).toContain("This is guidance, not a boundary"); + expect(container.textContent).toContain("Direct grant · Also inherited through Project One"); + expect(container.textContent).toContain("Inherited through projects (1)"); expect(container.textContent).toContain("Inherited through Project One · managed on those projects"); - expect(container.textContent).toContain("Inherited access"); const revoke = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Revoke direct grant")); await act(() => revoke!.click()); await act(() => new Promise((resolve) => setTimeout(resolve, 0))); diff --git a/ui/src/pages/RepositoryDetail.tsx b/ui/src/pages/RepositoryDetail.tsx index 984db4c3722..1347a795fc0 100644 --- a/ui/src/pages/RepositoryDetail.tsx +++ b/ui/src/pages/RepositoryDetail.tsx @@ -15,12 +15,13 @@ import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; +import { Input } from "@/components/ui/input"; function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) { return ( -
    - {label} - {value} +
    + {label} + {value}
    ); } @@ -37,24 +38,42 @@ function SectionPicker({ label, options, onSelect }: { onSelect: (id: string) => void; }) { const [open, setOpen] = useState(false); + const [search, setSearch] = useState(""); + const filtered = options.filter((option) => option.label.toLowerCase().includes(search.trim().toLowerCase())); + function handleOpenChange(next: boolean) { + setOpen(next); + if (!next) setSearch(""); + } return ( - + - - {options.map((option) => ( - - ))} + + setSearch(event.target.value)} + placeholder="Search" + /> +
    + {filtered.map((option) => ( + + ))} + {filtered.length === 0 ? ( +

    No options match.

    + ) : null} +
    ); @@ -128,6 +147,8 @@ export function RepositoryDetail() { const agentOptions = (agentsQuery.data ?? []) .filter((agent) => !directAgentIds.has(agent.id)) .map((agent) => ({ id: agent.id, label: agent.name })); + const directAgents = relationships?.agents.filter((agent) => agent.sources.direct) ?? []; + const inheritedAgents = relationships?.agents.filter((agent) => !agent.sources.direct) ?? []; if (repositoryQuery.isLoading || relationshipsQuery.isLoading) return ; if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) { @@ -142,10 +163,10 @@ export function RepositoryDetail() { return (
    -
    +
    -
    -

    +
    +

    {repository.owner}/{repository.name}

    {repository.host}

    @@ -173,8 +194,8 @@ export function RepositoryDetail() {
    -
    -
    +
    +

    Project hints

    Attach this repository to every project likely to touch it. This is guidance, not a boundary. @@ -185,8 +206,8 @@ export function RepositoryDetail() { {relationships.projects.length === 0 ? (

    No projects currently hint at this repository.

    ) : relationships.projects.map((project) => ( -
    - {project.name} +
    + {project.name} @@ -197,34 +218,47 @@ export function RepositoryDetail() {
    -
    -
    -

    Direct agent access

    -

    These grants are in addition to access inherited through projects.

    +
    +
    +

    Direct agent access ({directAgents.length})

    +

    Explicit grants managed on this repository.

    grantAgent.mutate(id)} />
    - {relationships.agents.length === 0 ? ( -

    No agents currently have effective access.

    - ) : relationships.agents.map((agent) => ( -
    + {directAgents.length === 0 ? ( +

    No agents have a direct grant.

    + ) : directAgents.map((agent) => ( +
    {agent.name}

    {agent.sources.projects.length > 0 - ? `Inherited through ${agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects` + ? `Direct grant · Also inherited through ${agent.sources.projects.map((project) => project.name).join(", ")}` : "Direct grant"}

    - {agent.sources.direct ? ( - - ) : ( - Inherited access - )} +
    ))} + {inheritedAgents.length > 0 ? ( +
    + + Inherited through projects ({inheritedAgents.length}) + +
    + {inheritedAgents.map((agent) => ( +
    + {agent.name} +

    + Inherited through {agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects +

    +
    + ))} +
    +
    + ) : null}
    );