fix(ui): harden repository catalog workflows

Restore the unprefixed route, surface manual and sync outcomes, improve discovery and relationship affordances, and make repository catalog/detail layouts usable on narrow viewports.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent e22e9aab41
commit 4613b36f58
10 files changed
+294 -93

No files matched your search

+1
View File
@@ -595,6 +595,7 @@ export function App() {
<Route path="projects/:projectId/workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="repositories" element={<UnprefixedBoardRedirect />} />
<Route path="repositories/:repositoryId" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
+4 -1
View File
@@ -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<HTMLButtonElement>;
/** 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 && (
<Button onClick={onAction}>
<Button ref={actionRef} onClick={onAction}>
{!hideActionIcon && <Plus className="h-4 w-4 mr-1.5" />}
{action}
</Button>
+1 -1
View File
@@ -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"]);
});
+13 -3
View File
@@ -13,6 +13,10 @@ function repositoryLabel(repository: Pick<RepositoryCatalogItem, "host" | "owner
return `${repository.host}/${repository.owner}/${repository.name}`;
}
function repositoryName(repository: Pick<RepositoryCatalogItem, "owner" | "name">) {
return `${repository.owner}/${repository.name}`;
}
export function RepositoryPicker({
companyId,
value,
@@ -74,9 +78,12 @@ export function RepositoryPicker({
<div className="space-y-1">
{selected.map((repository) => (
<div key={repository.id} className="flex items-center justify-between gap-2 rounded-md border border-border px-2 py-1.5">
<div className="flex min-w-0 items-center gap-2">
<div className="flex min-w-0 items-center gap-2" title={repositoryLabel(repository)}>
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-xs">{repositoryLabel(repository)}</span>
<span className="min-w-0">
<span className="block truncate text-xs">{repositoryName(repository)}</span>
<span className="block truncate text-(length:--text-micro) text-muted-foreground">{repository.host}</span>
</span>
{repository.state !== "active" ? (
<span className="text-(length:--text-micro) text-muted-foreground">{repository.state}</span>
) : null}
@@ -147,7 +154,10 @@ export function RepositoryPicker({
<span className="flex h-4 w-4 shrink-0 items-center justify-center rounded border border-border">
{checked ? <Check className="h-3 w-3" /> : null}
</span>
<span className="min-w-0 flex-1 truncate text-xs">{repositoryLabel(repository)}</span>
<span className="min-w-0 flex-1" title={repositoryLabel(repository)}>
<span className="block truncate text-xs">{repositoryName(repository)}</span>
<span className="block truncate text-(length:--text-micro) text-muted-foreground">{repository.host}</span>
</span>
{repository.projectCount > 0 ? (
<span className="text-(length:--text-micro) text-muted-foreground">
{repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"}
@@ -156,7 +156,6 @@ describe("GitHubConnectionDialog", () => {
repositoriesApiMock.discoverConnectionRepositories.mockResolvedValue(discoveryPage({ items: [], total: 0 }));
setInputValue(document.querySelector<HTMLInputElement>('input[aria-label="Search repositories"]')!, "zzz");
clickByText("Search");
await waitFor(() => {
expect(repositoriesApiMock.discoverConnectionRepositories).toHaveBeenCalledWith("conn-1", {
query: "zzz",
@@ -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<RepositoryDiscoveryItem[]>([]);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [search, setSearch] = useState("");
const deferredSearch = useDeferredValue(search);
const lastSearchRef = useRef("");
const [selected, setSelected] = useState<Set<string>>(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 (
<Dialog open={open} onOpenChange={handleOpenChange}>
@@ -220,19 +227,26 @@ export function GitHubConnectionDialog({
{step === "select" ? (
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<div className="relative">
<Input
aria-label="Search repositories"
value={search}
onChange={(event) => setSearch(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") runSearch();
}}
placeholder="Search repositories"
className={search ? "pr-8" : undefined}
/>
<Button type="button" variant="outline" onClick={runSearch} disabled={discoverMutation.isPending}>
Search
</Button>
{search ? (
<Button
type="button"
variant="ghost"
size="icon-xs"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={() => setSearch("")}
aria-label="Clear repository search"
>
<X className="h-3.5 w-3.5" />
</Button>
) : null}
</div>
{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}
>
+71 -2
View File
@@ -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<HTMLInputElement>("#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<HTMLInputElement>("#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");
});
});
+98 -32
View File
@@ -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<string | null>(null);
const [manualOpen, setManualOpen] = useState(false);
const [manualUrl, setManualUrl] = useState("");
const [catalogNotice, setCatalogNotice] = useState<string | null>(null);
const githubTriggerRef = useRef<HTMLButtonElement | null>(null);
const manualTriggerRef = useRef<HTMLButtonElement | null>(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<HTMLButtonElement | null>) {
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 = (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => setManualOpen(true)}>
<div className="flex w-full flex-wrap items-center justify-end gap-2 sm:w-auto">
<Button variant="outline" onClick={(event) => openManualDialog(event.currentTarget)}>
<Plus className="h-4 w-4" /> Add manually
</Button>
{githubProviders.length > 1 ? (
githubProviders.map((entry) => (
<Button key={entry.host} onClick={() => openGithubDialog(entry.host)}>
<Button key={entry.host} onClick={(event) => openGithubDialog(entry.host, event.currentTarget)}>
<Github className="h-4 w-4" /> Connect {entry.host}
</Button>
))
) : (
<Button onClick={() => openGithubDialog(defaultGithubHost)}>
) : githubProviders.length === 1 ? (
<Button onClick={(event) => openGithubDialog(defaultGithubHost, event.currentTarget)}>
<Github className="h-4 w-4" /> Connect GitHub
</Button>
)}
) : null}
</div>
);
return (
<div className="flex flex-col gap-6 p-6">
<div className="flex items-center justify-between gap-4">
<div>
<div className="flex flex-col items-start gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<h1 className="text-lg font-semibold text-foreground">Repositories</h1>
<p className="text-sm text-muted-foreground">
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 (
<Card key={connection.id} className="flex items-center justify-between gap-3 px-4 py-3">
<div className="flex items-center gap-3">
<Card key={connection.id} className="flex flex-row items-start justify-between gap-3 px-4 py-3 sm:items-center">
<div className="flex min-w-0 items-center gap-3">
<Github className="h-4 w-4 text-muted-foreground" aria-hidden />
<div>
<p className="text-sm font-medium text-foreground">
<div className="min-w-0">
<p className="break-words text-sm font-medium text-foreground">
{connection.accountName ?? connection.host}
</p>
<p className={`text-xs ${toneClass(tone)}`}>
{connectionHealthLabel(connection)}
{connection.syncError ? ` — ${connection.syncError}` : ""}
<p
role={syncMutation.isError && syncMutation.variables === connection.id ? "alert" : undefined}
className={`text-xs ${toneClass(tone)}`}
>
{syncMutation.isError && syncMutation.variables === connection.id
? "Sync failed — status refreshed; try again"
: connectionHealthLabel(connection)}
{connection.syncError && !(syncMutation.isError && syncMutation.variables === connection.id)
? ` — ${connection.syncError}`
: ""}
</p>
</div>
</div>
@@ -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 ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
@@ -215,14 +271,19 @@ export function Repositories() {
</div>
) : null}
{catalogNotice ? <p role="status" className="text-sm text-muted-foreground">{catalogNotice}</p> : null}
{repositories.length === 0 ? (
<EmptyState
icon={GitBranch}
title="No repositories yet"
message="Connect GitHub to import repositories, or add one manually by its clone URL."
action="Connect GitHub"
message={githubProviders.length > 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)}
/>
) : (
<div className="flex flex-col gap-3">
@@ -243,7 +304,7 @@ export function Repositories() {
<li key={repo.id}>
<Link
to={repo.id}
className="flex items-center justify-between gap-3 px-3 py-2.5 hover:bg-muted/50"
className="flex flex-col items-start gap-2 px-3 py-2.5 hover:bg-muted/50 sm:flex-row sm:items-center sm:justify-between sm:gap-3"
>
<div className="flex min-w-0 items-center gap-3">
<GitBranch className="h-4 w-4 shrink-0 text-muted-foreground" aria-hidden />
@@ -254,7 +315,7 @@ export function Repositories() {
<p className="truncate text-xs text-muted-foreground">{repo.host}</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 pl-7 sm:shrink-0 sm:pl-0">
{repo.state !== "active" ? (
<Badge variant="outline" className="text-warning">{repo.state}</Badge>
) : null}
@@ -278,11 +339,11 @@ export function Repositories() {
companyId={selectedCompanyId!}
open={githubOpen}
host={githubHost}
onOpenChange={setGithubOpen}
onOpenChange={handleGithubOpenChange}
onImported={() => refresh()}
/>
<Dialog open={manualOpen} onOpenChange={setManualOpen}>
<Dialog open={manualOpen} onOpenChange={handleManualOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add a repository manually</DialogTitle>
@@ -294,14 +355,19 @@ export function Repositories() {
<Label htmlFor="manual-clone-url">Clone URL</Label>
<Input
id="manual-clone-url"
aria-invalid={manualMutation.isError}
aria-describedby={manualMutation.isError ? "manual-clone-url-error" : undefined}
value={manualUrl}
onChange={(event) => setManualUrl(event.target.value)}
onChange={(event) => {
setManualUrl(event.target.value);
if (manualMutation.isError) manualMutation.reset();
}}
placeholder="https://github.com/owner/repo.git"
/>
{manualMutation.isError ? (
<p role="alert" className="flex items-center gap-2 text-sm text-destructive">
<p id="manual-clone-url-error" role="alert" className="flex items-center gap-2 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{(manualMutation.error as { message?: string } | null)?.message ?? "Failed to add repository."}
{apiErrorMessage(manualMutation.error, "Failed to add repository.")}
</p>
) : null}
</div>
+2 -1
View File
@@ -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)));
+72 -38
View File
@@ -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 (
<div className="flex items-start justify-between gap-4 py-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-right text-sm text-foreground">{value}</span>
<div className="flex flex-col gap-1 py-2 sm:flex-row sm:items-start sm:justify-between sm:gap-4">
<span className="shrink-0 text-sm text-muted-foreground">{label}</span>
<span className="min-w-0 break-all text-left text-sm text-foreground sm:text-right">{value}</span>
</div>
);
}
@@ -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 (
<Popover open={open} onOpenChange={setOpen}>
<Popover open={open} onOpenChange={handleOpenChange}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="xs" disabled={options.length === 0}>
<Plus className="mr-1 h-3 w-3" />{label}
</Button>
</PopoverTrigger>
<PopoverContent className="w-64 p-1" align="end">
{options.map((option) => (
<button
key={option.id}
type="button"
className="w-full truncate rounded px-2 py-1.5 text-left text-xs hover:bg-accent"
onClick={() => { onSelect(option.id); setOpen(false); }}
>
{option.label}
</button>
))}
<PopoverContent className="w-64 space-y-2 p-2" align="end">
<Input
aria-label={`Search ${label.toLowerCase()} options`}
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="Search"
/>
<div className="max-h-56 overflow-y-auto">
{filtered.map((option) => (
<button
key={option.id}
type="button"
title={option.label}
className="w-full break-words rounded px-2 py-1.5 text-left text-xs hover:bg-accent"
onClick={() => { onSelect(option.id); handleOpenChange(false); }}
>
{option.label}
</button>
))}
{filtered.length === 0 ? (
<p className="px-2 py-3 text-center text-xs text-muted-foreground">No options match.</p>
) : null}
</div>
</PopoverContent>
</Popover>
);
@@ -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 <PageSkeleton />;
if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) {
@@ -142,10 +163,10 @@ export function RepositoryDetail() {
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<div className="flex items-center gap-3">
<div className="flex min-w-0 items-start gap-3">
<GitBranch className="h-5 w-5 text-muted-foreground" aria-hidden />
<div>
<h1 className="text-lg font-semibold text-foreground">
<div className="min-w-0">
<h1 className="break-words text-lg font-semibold text-foreground">
{repository.owner}/{repository.name}
</h1>
<p className="text-sm text-muted-foreground">{repository.host}</p>
@@ -173,8 +194,8 @@ export function RepositoryDetail() {
<Separator />
<section className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
<div className="min-w-0">
<h2 className="text-sm font-medium">Project hints</h2>
<p className="text-xs text-muted-foreground">
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 ? (
<p className="text-xs text-muted-foreground">No projects currently hint at this repository.</p>
) : relationships.projects.map((project) => (
<div key={project.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
<Link to={`/projects/${project.id}/configuration`} className="text-sm hover:underline">{project.name}</Link>
<div key={project.id} className="flex flex-col items-start gap-2 border-b border-border py-2 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<Link to={`/projects/${project.id}/configuration`} className="break-words text-sm hover:underline">{project.name}</Link>
<Button type="button" variant="ghost" size="xs" onClick={() => detachProject.mutate(project.id)}>
Remove project hint
</Button>
@@ -197,34 +218,47 @@ export function RepositoryDetail() {
<Separator />
<section className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-sm font-medium">Direct agent access</h2>
<p className="text-xs text-muted-foreground">These grants are in addition to access inherited through projects.</p>
<div className="flex flex-col items-start gap-3 sm:flex-row sm:justify-between sm:gap-4">
<div className="min-w-0">
<h2 className="text-sm font-medium">Direct agent access ({directAgents.length})</h2>
<p className="text-xs text-muted-foreground">Explicit grants managed on this repository.</p>
</div>
<SectionPicker label="Grant agent" options={agentOptions} onSelect={(id) => grantAgent.mutate(id)} />
</div>
{relationships.agents.length === 0 ? (
<p className="text-xs text-muted-foreground">No agents currently have effective access.</p>
) : relationships.agents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between gap-3 border-b border-border py-2 last:border-b-0">
{directAgents.length === 0 ? (
<p className="text-xs text-muted-foreground">No agents have a direct grant.</p>
) : directAgents.map((agent) => (
<div key={agent.id} className="flex flex-col items-start gap-2 border-b border-border py-2 last:border-b-0 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<div className="min-w-0">
<Link to={`/agents/${agent.id}`} className="text-sm hover:underline">{agent.name}</Link>
<p className="text-(length:--text-micro) text-muted-foreground">
{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"}
</p>
</div>
{agent.sources.direct ? (
<Button type="button" variant="ghost" size="xs" onClick={() => revokeAgent.mutate(agent.id)}>
Revoke direct grant
</Button>
) : (
<span className="text-(length:--text-micro) text-muted-foreground">Inherited access</span>
)}
<Button type="button" variant="ghost" size="xs" onClick={() => revokeAgent.mutate(agent.id)}>
Revoke direct grant
</Button>
</div>
))}
{inheritedAgents.length > 0 ? (
<details className="border-t border-border pt-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground">
Inherited through projects ({inheritedAgents.length})
</summary>
<div className="mt-2">
{inheritedAgents.map((agent) => (
<div key={agent.id} className="flex flex-col gap-1 border-b border-border py-2 last:border-b-0">
<Link to={`/agents/${agent.id}`} className="text-sm hover:underline">{agent.name}</Link>
<p className="text-(length:--text-micro) text-muted-foreground">
Inherited through {agent.sources.projects.map((project) => project.name).join(", ")} · managed on those projects
</p>
</div>
))}
</div>
</details>
) : null}
</section>
</div>
);