feat(ui): add repository hint and grant editors

Replace project Repo URL ownership with a reusable zero-to-many repository picker and nested add flow, keep execution workspace controls separate, and add source-aware repository relationship editing with focused UI coverage.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-12 02:34:33 +00:00
1 parent 01bd270119
commit 358b2863f2
10 files changed
+651 -231

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/:repositoryId" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
+3
View File
@@ -11,6 +11,7 @@ import type {
RepositoryCatalogItem,
RepositoryConnection,
RepositoryDiscoveryPage,
RepositoryRelationships,
UpdateRepository,
} from "@paperclipai/shared";
import { api } from "./client";
@@ -69,6 +70,8 @@ export const repositoriesApi = {
`/companies/${encodeURIComponent(companyId)}/repositories${opts.includeArchived ? "?includeArchived=true" : ""}`,
),
get: (repositoryId: string) => api.get<Repository>(`/repositories/${encodeURIComponent(repositoryId)}`),
getRelationships: (repositoryId: string) =>
api.get<RepositoryRelationships>(`/repositories/${encodeURIComponent(repositoryId)}/relationships`),
createManual: (companyId: string, input: CreateManualRepository) =>
api.post<Repository>(`/companies/${encodeURIComponent(companyId)}/repositories`, input),
update: (repositoryId: string, input: UpdateRepository) =>
+27 -73
View File
@@ -37,6 +37,8 @@ import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
import { RepositoryPicker } from "./RepositoryPicker";
import { buildNewProjectRequests } from "../lib/new-project-requests";
const projectStatuses = [
{ value: "backlog", label: "Backlog" },
@@ -57,7 +59,7 @@ export function NewProjectDialog() {
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [repositoryIds, setRepositoryIds] = useState<string[]>([]);
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
@@ -109,75 +111,36 @@ export function NewProjectDialog() {
setTargetDate("");
setExpanded(false);
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
setRepositoryIds([]);
setWorkspaceError(null);
}
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const looksLikeRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const deriveWorkspaceNameFromPath = (value: string) => {
const normalized = value.trim().replace(/[\\/]+$/, "");
const segments = normalized.split(/[\\/]/).filter(Boolean);
return segments[segments.length - 1] ?? "Local folder";
};
const deriveWorkspaceNameFromRepo = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
const repo = segments[segments.length - 1]?.replace(/\.git$/i, "") ?? "";
return repo || "GitHub repo";
} catch {
return "GitHub repo";
}
};
async function handleSubmit() {
if (!selectedCompanyId || !name.trim()) return;
const localPath = workspaceLocalPath.trim();
const repoUrl = workspaceRepoUrl.trim();
if (localPath && !isAbsolutePath(localPath)) {
setWorkspaceError("Local folder must be a full absolute path.");
return;
}
if (repoUrl && !looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return;
}
setWorkspaceError(null);
try {
const created = await createProject.mutateAsync({
name: name.trim(),
description: description.trim() || undefined,
const requests = buildNewProjectRequests({
name,
description,
status,
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
goalIds,
targetDate,
repositoryIds,
workspaceLocalPath,
});
const created = await createProject.mutateAsync(requests.project);
if (localPath || repoUrl) {
const workspacePayload: Record<string, unknown> = {
name: localPath
? deriveWorkspaceNameFromPath(localPath)
: deriveWorkspaceNameFromRepo(repoUrl),
...(localPath ? { cwd: localPath } : {}),
...(repoUrl ? { repoUrl } : {}),
};
await projectsApi.createWorkspace(created.id, workspacePayload);
if (requests.workspace) {
await projectsApi.createWorkspace(created.id, requests.workspace);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
@@ -280,37 +243,28 @@ export function NewProjectDialog() {
</div>
<div className="px-4 pt-3 pb-3 space-y-3 border-t border-border">
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Repo URL</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Link a GitHub repository so agents can clone, read, and push code for this project.
</TooltipContent>
</Tooltip>
{selectedCompanyId ? (
<div className="space-y-1.5">
<div>
<p className="text-xs text-muted-foreground">Repository hints</p>
<p className="text-(length:--text-micro) text-muted-foreground">
Add the repositories this project will most likely touch. Agents may use other accessible repositories.
</p>
</div>
<RepositoryPicker companyId={selectedCompanyId} value={repositoryIds} onChange={setRepositoryIds} />
</div>
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => { setWorkspaceRepoUrl(e.target.value); setWorkspaceError(null); }}
placeholder="https://github.com/org/repo"
/>
</div>
) : null}
<div>
<div className="border-t border-border pt-3">
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</label>
<label className="block text-xs text-muted-foreground">Execution workspace</label>
<span className="text-xs text-muted-foreground/50">optional</span>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<HelpCircle className="h-3 w-3 text-muted-foreground/50 cursor-help" />
</TooltipTrigger>
<TooltipContent side="top" className="max-w-(--sz-240px) text-xs">
Set an absolute path on this machine where local agents will read and write files for this project.
This local folder is managed separately from repository hints and controls where local agents write files.
</TooltipContent>
</Tooltip>
</div>
+29 -123
View File
@@ -24,6 +24,7 @@ import { DraftInput } from "./agent-config-primitives";
import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
import { RepositoryPicker } from "./RepositoryPicker";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
@@ -49,6 +50,7 @@ export type ProjectConfigFieldKey =
| "status"
| "goals"
| "env"
| "repository_hints"
| "execution_workspace_enabled"
| "execution_workspace_default_mode"
| "execution_workspace_shared_concurrency"
@@ -252,9 +254,8 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const queryClient = useQueryClient();
const [goalOpen, setGoalOpen] = useState(false);
const [executionWorkspaceAdvancedOpen, setExecutionWorkspaceAdvancedOpen] = useState(false);
const [workspaceMode, setWorkspaceMode] = useState<"local" | "repo" | null>(null);
const [workspaceMode, setWorkspaceMode] = useState<"local" | null>(null);
const [workspaceCwd, setWorkspaceCwd] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const commitField = (field: ProjectConfigFieldKey, data: Record<string, unknown>) => {
@@ -367,7 +368,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
mutationFn: (data: Record<string, unknown>) => projectsApi.createWorkspace(project.id, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@@ -378,7 +378,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@@ -389,7 +388,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
projectsApi.updateWorkspace(project.id, workspaceId, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@@ -422,17 +420,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
const isAbsolutePath = (value: string) => value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value);
const looksLikeRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
if (parsed.protocol !== "https:") return false;
const segments = parsed.pathname.split("/").filter(Boolean);
return segments.length >= 2;
} catch {
return false;
}
};
const isSafeExternalUrl = (value: string | null | undefined) => {
if (!value) return false;
try {
@@ -503,21 +490,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
persistCodebase({ cwd });
};
const submitRepoWorkspace = () => {
const repoUrl = workspaceRepoUrl.trim();
if (!repoUrl) {
setWorkspaceError(null);
persistCodebase({ repoUrl: null });
return;
}
if (!looksLikeRepoUrl(repoUrl)) {
setWorkspaceError("Repo must use a valid GitHub or GitHub Enterprise repo URL.");
return;
}
setWorkspaceError(null);
persistCodebase({ repoUrl });
};
const clearLocalWorkspace = () => {
const confirmed = window.confirm(
codebase.repoUrl
@@ -528,24 +500,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
persistCodebase({ cwd: null });
};
const clearRepoWorkspace = () => {
const hasLocalFolder = Boolean(codebase.localFolder);
const confirmed = window.confirm(
hasLocalFolder
? "Clear repo from this workspace?"
: "Delete this workspace repo?",
);
if (!confirmed) return;
if (primaryCodebaseWorkspace && hasLocalFolder) {
updateWorkspace.mutate({
workspaceId: primaryCodebaseWorkspace.id,
data: { repoUrl: null, repoRef: null, defaultRef: null, sourceType: deriveSourceType(codebase.localFolder, null) },
});
return;
}
persistCodebase({ repoUrl: null });
};
return (
<div>
<div className="space-y-1 pb-4">
@@ -696,28 +650,48 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<Separator className="my-4" />
<div className="space-y-2 py-4">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Repository hints</span>
<SaveIndicator state={fieldState("repository_hints")} />
</div>
<p className="text-xs text-muted-foreground">
Add the repositories this project will most likely touch. Agents may use other accessible repositories.
</p>
{selectedCompanyId ? (
<RepositoryPicker
companyId={selectedCompanyId}
value={(project.repositoryHints ?? []).map((repository) => repository.id)}
onChange={(repositoryIds) => commitField("repository_hints", { repositoryIds })}
disabled={!onUpdate && !onFieldUpdate}
/>
) : null}
</div>
<Separator className="my-4" />
<div className="space-y-1 py-4">
<div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Codebase</span>
<span>Execution workspace</span>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border text-(length:--text-nano) text-muted-foreground hover:text-foreground"
aria-label="Codebase help"
aria-label="Execution workspace help"
>
?
</button>
</TooltipTrigger>
<TooltipContent side="top">
Repo identifies the source of truth. Local folder is the default place agents write code.
The writable local folder is managed separately from non-exclusive repository hints.
</TooltipContent>
</Tooltip>
</div>
<div className="space-y-2 rounded-md border border-border/70 p-3">
<div className="space-y-1">
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Repo</div>
<div className="text-(length:--text-micro) uppercase tracking-wide text-muted-foreground">Legacy remote</div>
{codebase.repoUrl ? (
<div className="flex items-center justify-between gap-2">
{isSafeExternalUrl(codebase.repoUrl) ? (
@@ -737,45 +711,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<span className="break-all min-w-0">{codebase.repoUrl}</span>
</div>
)}
<div className="flex items-center gap-1">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("repo");
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
setWorkspaceError(null);
}}
>
Change repo
</Button>
<Button
variant="ghost"
size="icon-xs"
onClick={clearRepoWorkspace}
aria-label="Clear repo"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
<span className="text-(length:--text-micro) text-muted-foreground">Workspace source (read-only)</span>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">Not set.</div>
<Button
variant="outline"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode("repo");
setWorkspaceRepoUrl(codebase.repoUrl ?? "");
setWorkspaceError(null);
}}
>
Set repo
</Button>
</div>
<div className="text-xs text-muted-foreground">No legacy remote configured.</div>
)}
</div>
@@ -905,39 +844,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</div>
)}
{workspaceMode === "repo" && (
<div className="space-y-1.5 rounded-md border border-border p-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs outline-none"
value={workspaceRepoUrl}
onChange={(e) => setWorkspaceRepoUrl(e.target.value)}
placeholder="https://github.com/org/repo"
/>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="xs"
className="h-6 px-2"
disabled={(!workspaceRepoUrl.trim() && !primaryCodebaseWorkspace) || createWorkspace.isPending || updateWorkspace.isPending}
onClick={submitRepoWorkspace}
>
Save
</Button>
<Button
variant="ghost"
size="xs"
className="h-6 px-2"
onClick={() => {
setWorkspaceMode(null);
setWorkspaceRepoUrl("");
setWorkspaceError(null);
}}
>
Cancel
</Button>
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
+130
View File
@@ -0,0 +1,130 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RepositoryPicker } from "./RepositoryPicker";
import { buildNewProjectRequests } from "../lib/new-project-requests";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const repositoriesApi = vi.hoisted(() => ({ list: vi.fn(), createManual: vi.fn() }));
vi.mock("../api/repositories", () => ({ repositoriesApi }));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
function repository(id: string, name: string, projectCount = 0) {
return {
id,
companyId: "company-1",
connectionId: null,
provider: "manual",
providerRepositoryId: null,
host: "github.com",
owner: "acme",
name,
cloneUrl: `https://github.com/acme/${name}.git`,
webUrl: `https://github.com/acme/${name}`,
defaultBranch: "main",
visibility: "private",
state: "active",
unavailableReason: null,
providerMetadata: null,
archivedAt: null,
createdAt: new Date(),
updatedAt: new Date(),
projectCount,
directGrantCount: 0,
} as const;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void>;
flushSync(() => { result = callback(); });
await result!;
}
let root: Root;
let container: HTMLDivElement;
let client: QueryClient;
beforeEach(() => {
repositoriesApi.list.mockResolvedValue([]);
client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
async function render(value: string[], onChange = vi.fn()) {
await act(() => root.render(
<QueryClientProvider client={client}>
<RepositoryPicker companyId="company-1" value={value} onChange={onChange} />
</QueryClientProvider>,
));
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
return onChange;
}
describe("RepositoryPicker", () => {
it("supports non-code projects with zero repository hints", async () => {
await render([]);
expect(container.textContent).toContain("No repository hints");
});
it("selects reusable company repositories for cross-repository projects", async () => {
repositoriesApi.list.mockResolvedValue([
repository("repo-app", "app", 2),
repository("repo-docs", "docs", 1),
]);
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"));
await act(() => docs!.click());
expect(onChange).toHaveBeenCalledWith(["repo-app", "repo-docs"]);
});
it("returns a nested newly-created repository as selected", async () => {
const created = repository("repo-new", "new");
repositoriesApi.createManual.mockResolvedValue(created);
const onChange = await render([]);
const addNew = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Add new repository"));
await act(() => addNew!.click());
const cloneInput = container.querySelector<HTMLInputElement>('input[placeholder="https://github.com/org/repo.git"]')!;
await act(() => {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")!.set!;
setter.call(cloneInput, "https://github.com/acme/new.git");
cloneInput.dispatchEvent(new Event("input", { bubbles: true }));
});
const submit = [...container.querySelectorAll("button")].find((button) => button.textContent?.includes("Add and select"));
await act(() => submit!.click());
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
expect(repositoriesApi.createManual).toHaveBeenCalled();
expect(onChange).toHaveBeenCalledWith(["repo-new"]);
});
it("keeps repository hints out of execution workspace creation", () => {
expect(buildNewProjectRequests({
name: "Research",
description: "",
status: "planned",
goalIds: [],
targetDate: "",
repositoryIds: ["repo-app", "repo-docs"],
workspaceLocalPath: "",
})).toEqual({
project: { name: "Research", description: undefined, status: "planned", repositoryIds: ["repo-app", "repo-docs"] },
workspace: null,
});
});
});
+171
View File
@@ -0,0 +1,171 @@
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, GitBranch, Plus, Search, X } from "lucide-react";
import type { RepositoryCatalogItem } from "@paperclipai/shared";
import { repositoriesApi } from "../api/repositories";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { cn } from "@/lib/utils";
function repositoryLabel(repository: Pick<RepositoryCatalogItem, "host" | "owner" | "name">) {
return `${repository.host}/${repository.owner}/${repository.name}`;
}
export function RepositoryPicker({
companyId,
value,
onChange,
disabled = false,
}: {
companyId: string;
value: string[];
onChange: (repositoryIds: string[]) => void;
disabled?: boolean;
}) {
const queryClient = useQueryClient();
const [open, setOpen] = useState(false);
const [search, setSearch] = useState("");
const [adding, setAdding] = useState(false);
const [cloneUrl, setCloneUrl] = useState("");
const [defaultBranch, setDefaultBranch] = useState("");
const repositoriesQuery = useQuery({
queryKey: queryKeys.repositories.list(companyId, { includeArchived: true }),
queryFn: () => repositoriesApi.list(companyId, { includeArchived: true }),
enabled: Boolean(companyId),
});
const repositories = repositoriesQuery.data ?? [];
const selected = value
.map((id) => repositories.find((repository) => repository.id === id))
.filter((repository): repository is RepositoryCatalogItem => Boolean(repository));
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
const selectable = repositories.filter((repository) => repository.state === "active" && !value.includes(repository.id));
if (!needle) return selectable;
return selectable.filter((repository) => repositoryLabel(repository).toLowerCase().includes(needle));
}, [repositories, search, value]);
const createRepository = useMutation({
mutationFn: () => repositoriesApi.createManual(companyId, {
cloneUrl: cloneUrl.trim(),
defaultBranch: defaultBranch.trim() || null,
visibility: "unknown",
}),
onSuccess: (repository) => {
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.all(companyId) });
if (!value.includes(repository.id)) onChange([...value, repository.id]);
setCloneUrl("");
setDefaultBranch("");
setAdding(false);
},
});
const toggleRepository = (repositoryId: string) => {
onChange(
value.includes(repositoryId)
? value.filter((id) => id !== repositoryId)
: [...value, repositoryId],
);
};
return (
<div className="space-y-2">
{selected.length > 0 ? (
<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">
<GitBranch className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate text-xs">{repositoryLabel(repository)}</span>
{repository.state !== "active" ? (
<span className="text-(length:--text-micro) text-muted-foreground">{repository.state}</span>
) : null}
</div>
<Button
type="button"
variant="ghost"
size="xs"
className="h-6 px-2 text-muted-foreground"
onClick={() => toggleRepository(repository.id)}
disabled={disabled}
aria-label={`Remove project hint ${repositoryLabel(repository)}`}
>
Remove
</Button>
</div>
))}
</div>
) : (
<p className="text-xs text-muted-foreground">No repository hints. This project can still contain non-code work.</p>
)}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button type="button" variant="outline" size="xs" disabled={disabled || !companyId}>
<Plus className="mr-1 h-3 w-3" />
Add repository
</Button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2" align="start">
{adding ? (
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs font-medium">Add company repository</p>
<Button type="button" variant="ghost" size="icon-xs" onClick={() => setAdding(false)} aria-label="Back to repositories">
<X className="h-3 w-3" />
</Button>
</div>
<Input value={cloneUrl} onChange={(event) => setCloneUrl(event.target.value)} placeholder="https://github.com/org/repo.git" />
<Input value={defaultBranch} onChange={(event) => setDefaultBranch(event.target.value)} placeholder="Default branch (optional)" />
{createRepository.isError ? <p className="text-xs text-destructive">Could not add this repository.</p> : null}
<Button
type="button"
size="sm"
className="w-full"
disabled={!cloneUrl.trim() || createRepository.isPending}
onClick={() => createRepository.mutate()}
>
{createRepository.isPending ? "Adding…" : "Add and select"}
</Button>
</div>
) : (
<div className="space-y-2">
<div className="relative">
<Search className="absolute left-2 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input value={search} onChange={(event) => setSearch(event.target.value)} className="pl-7" placeholder="Search company repositories" />
</div>
<div className="max-h-56 space-y-0.5 overflow-y-auto">
{filtered.map((repository) => {
const checked = value.includes(repository.id);
return (
<button
key={repository.id}
type="button"
className={cn("flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left hover:bg-accent", checked && "bg-accent")}
onClick={() => toggleRepository(repository.id)}
>
<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>
{repository.projectCount > 0 ? (
<span className="text-(length:--text-micro) text-muted-foreground">
{repository.projectCount} {repository.projectCount === 1 ? "project" : "projects"}
</span>
) : null}
</button>
);
})}
{filtered.length === 0 ? <p className="px-2 py-3 text-center text-xs text-muted-foreground">No repositories match.</p> : null}
</div>
<Button type="button" variant="ghost" size="sm" className="w-full justify-start" onClick={() => setAdding(true)}>
<Plus className="mr-1 h-3.5 w-3.5" />
Add new repository
</Button>
</div>
)}
</PopoverContent>
</Popover>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
export function buildNewProjectRequests(input: {
name: string;
description: string;
status: string;
goalIds: string[];
targetDate: string;
repositoryIds: string[];
workspaceLocalPath: string;
}) {
const localPath = input.workspaceLocalPath.trim();
const normalizedPath = localPath.replace(/[\\/]+$/, "");
const segments = normalizedPath.split(/[\\/]/).filter(Boolean);
return {
project: {
name: input.name.trim(),
description: input.description.trim() || undefined,
status: input.status,
...(input.goalIds.length > 0 ? { goalIds: input.goalIds } : {}),
repositoryIds: input.repositoryIds,
...(input.targetDate ? { targetDate: input.targetDate } : {}),
},
workspace: localPath
? { name: segments[segments.length - 1] ?? "Local folder", cwd: localPath }
: null,
};
}
+3 -1
View File
@@ -295,11 +295,13 @@ export const queryKeys = {
detail: (id: string) => ["projects", "detail", id] as const,
},
repositories: {
all: (companyId: string) => ["repositories", companyId] as const,
list: (companyId: string, opts: { includeArchived?: boolean } = {}) =>
["repositories", companyId, { includeArchived: opts.includeArchived === true }] as const,
connections: (companyId: string) => ["repositories", companyId, "connections"] as const,
providers: (companyId: string) => ["repositories", companyId, "providers"] as const,
detail: (id: string) => ["repositories", "detail", id] as const,
detail: (repositoryId: string) => ["repositories", "detail", repositoryId] as const,
relationships: (repositoryId: string) => ["repositories", "relationships", repositoryId] as const,
},
cases: {
list: (companyId: string) => ["cases", companyId] as const,
+98
View File
@@ -0,0 +1,98 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { RepositoryDetail } from "./RepositoryDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const repositoriesApi = vi.hoisted(() => ({
get: vi.fn(),
getRelationships: vi.fn(),
attachProjectRepository: vi.fn(),
detachProjectRepository: vi.fn(),
grantAgentRepository: vi.fn(),
revokeAgentRepository: vi.fn(),
}));
vi.mock("../api/repositories", () => ({ repositoriesApi }));
vi.mock("../api/projects", () => ({ projectsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children: React.ReactNode; to: string }) => <a href={to}>{children}</a>,
useParams: () => ({ repositoryId: "repo-1" }),
}));
vi.mock("../context/CompanyContext", () => ({ useCompany: () => ({ selectedCompanyId: "company-1" }) }));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }) }));
vi.mock("@/components/ui/popover", () => ({
Popover: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverTrigger: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void>;
flushSync(() => { result = callback(); });
await result!;
}
describe("RepositoryDetail", () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(() => {
repositoriesApi.get.mockResolvedValue({
id: "repo-1",
owner: "acme",
name: "app",
host: "github.com",
defaultBranch: "main",
});
repositoriesApi.getRelationships.mockResolvedValue({
projects: [{ id: "project-1", name: "Project One", displayOrder: 0 }],
agents: [
{
id: "agent-direct",
name: "Direct and inherited",
title: "Engineer",
sources: { direct: true, projects: [{ id: "project-1", name: "Project One" }] },
},
{
id: "agent-inherited",
name: "Inherited only",
title: "Engineer",
sources: { direct: false, projects: [{ id: "project-1", name: "Project One" }] },
},
],
});
repositoriesApi.revokeAgentRepository.mockResolvedValue({ removed: true });
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(() => root.unmount());
container.remove();
vi.clearAllMocks();
});
it("distinguishes direct grants from inherited project access and revokes only the direct source", async () => {
const client = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } });
await act(() => root.render(
<QueryClientProvider client={client}>
<RepositoryDetail />
</QueryClientProvider>,
));
await act(() => new Promise((resolve) => setTimeout(resolve, 0)));
expect(container.textContent).toContain("This is guidance, not a boundary");
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)));
expect(repositoriesApi.revokeAgentRepository).toHaveBeenCalledWith("agent-direct", "repo-1");
});
});
+163 -34
View File
@@ -1,14 +1,20 @@
import { useEffect } from "react";
import { useParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Repository } from "@paperclipai/shared";
import { AlertCircle, GitBranch } from "lucide-react";
import { AlertCircle, GitBranch, Plus } from "lucide-react";
import { Link, useParams } from "@/lib/router";
import { agentsApi } from "../api/agents";
import { projectsApi } from "../api/projects";
import { repositoriesApi } from "../api/repositories";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { PageSkeleton } from "../components/PageSkeleton";
import { Badge } from "@/components/ui/badge";
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";
function MetadataRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
@@ -25,28 +31,106 @@ function stateTone(repository: Repository): string {
return "text-warning";
}
function SectionPicker({ label, options, onSelect }: {
label: string;
options: Array<{ id: string; label: string }>;
onSelect: (id: string) => void;
}) {
const [open, setOpen] = useState(false);
return (
<Popover open={open} onOpenChange={setOpen}>
<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>
</Popover>
);
}
export function RepositoryDetail() {
const { repositoryId } = useParams<{ repositoryId: string }>();
const { repositoryId = "" } = useParams();
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const repositoryQuery = useQuery({
queryKey: queryKeys.repositories.detail(repositoryId!),
queryFn: () => repositoriesApi.get(repositoryId!),
enabled: !!repositoryId,
queryKey: queryKeys.repositories.detail(repositoryId),
queryFn: () => repositoriesApi.get(repositoryId),
enabled: Boolean(repositoryId),
});
const relationshipsQuery = useQuery({
queryKey: queryKeys.repositories.relationships(repositoryId),
queryFn: () => repositoriesApi.getRelationships(repositoryId),
enabled: Boolean(repositoryId),
});
const projectsQuery = useQuery({
queryKey: queryKeys.projects.list(selectedCompanyId ?? ""),
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const agentsQuery = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId ?? ""),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const refresh = () => {
queryClient.invalidateQueries({ queryKey: queryKeys.repositories.relationships(repositoryId) });
if (selectedCompanyId) queryClient.invalidateQueries({ queryKey: queryKeys.repositories.all(selectedCompanyId) });
};
const attachProject = useMutation({
mutationFn: (projectId: string) => repositoriesApi.attachProjectRepository(projectId, repositoryId),
onSuccess: refresh,
});
const detachProject = useMutation({
mutationFn: (projectId: string) => repositoriesApi.detachProjectRepository(projectId, repositoryId),
onSuccess: refresh,
});
const grantAgent = useMutation({
mutationFn: (agentId: string) => repositoriesApi.grantAgentRepository(agentId, repositoryId),
onSuccess: refresh,
});
const revokeAgent = useMutation({
mutationFn: (agentId: string) => repositoriesApi.revokeAgentRepository(agentId, repositoryId),
onSuccess: refresh,
});
const repository = repositoryQuery.data;
const relationships = relationshipsQuery.data;
useEffect(() => {
setBreadcrumbs([
{ label: "Repositories", href: "/repositories" },
{ label: repository ? `${repository.owner}/${repository.name}` : "Repository" },
]);
}, [setBreadcrumbs, repository]);
return () => setBreadcrumbs([]);
}, [repository, setBreadcrumbs]);
if (repositoryQuery.isLoading) return <PageSkeleton />;
const projectOptions = useMemo(() => {
const linked = new Set(relationships?.projects.map((project) => project.id) ?? []);
return (projectsQuery.data ?? [])
.filter((project) => !linked.has(project.id) && !project.archivedAt)
.map((project) => ({ id: project.id, label: project.name }));
}, [projectsQuery.data, relationships?.projects]);
const directAgentIds = new Set(
relationships?.agents.filter((agent) => agent.sources.direct).map((agent) => agent.id) ?? [],
);
const agentOptions = (agentsQuery.data ?? [])
.filter((agent) => !directAgentIds.has(agent.id))
.map((agent) => ({ id: agent.id, label: agent.name }));
if (repositoryQuery.isError || !repository) {
if (repositoryQuery.isLoading || relationshipsQuery.isLoading) return <PageSkeleton />;
if (repositoryQuery.isError || relationshipsQuery.isError || !repository || !relationships) {
return (
<div className="p-6">
<div role="alert" className="flex items-center gap-2 text-sm text-destructive">
@@ -57,7 +141,7 @@ export function RepositoryDetail() {
}
return (
<div className="flex flex-col gap-6 p-6">
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
<div className="flex items-center gap-3">
<GitBranch className="h-5 w-5 text-muted-foreground" aria-hidden />
<div>
@@ -71,32 +155,77 @@ export function RepositoryDetail() {
<Card className="px-4 py-2">
<MetadataRow label="State" value={<span className={stateTone(repository)}>{repository.state}</span>} />
{repository.unavailableReason ? (
<MetadataRow label="Reason" value={repository.unavailableReason} />
) : null}
{repository.unavailableReason ? <MetadataRow label="Reason" value={repository.unavailableReason} /> : null}
<MetadataRow label="Visibility" value={repository.visibility} />
<MetadataRow label="Default branch" value={repository.defaultBranch ?? "—"} />
<MetadataRow
label="Clone URL"
value={<code className="text-xs">{repository.cloneUrl}</code>}
/>
<MetadataRow label="Clone URL" value={<code className="text-xs">{repository.cloneUrl}</code>} />
<MetadataRow
label="Web URL"
value={
repository.webUrl ? (
<a href={repository.webUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{repository.webUrl}
</a>
) : (
"—"
)
}
/>
<MetadataRow
label="Source"
value={repository.connectionId ? "Provider connection" : "Manual"}
value={repository.webUrl ? (
<a href={repository.webUrl} target="_blank" rel="noopener noreferrer" className="text-primary underline">
{repository.webUrl}
</a>
) : "—"}
/>
<MetadataRow label="Source" value={repository.connectionId ? "Provider connection" : "Manual"} />
</Card>
<Separator />
<section className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div>
<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.
</p>
</div>
<SectionPicker label="Add to project" options={projectOptions} onSelect={(id) => attachProject.mutate(id)} />
</div>
{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>
<Button type="button" variant="ghost" size="xs" onClick={() => detachProject.mutate(project.id)}>
Remove project hint
</Button>
</div>
))}
</section>
<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>
<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">
<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"}
</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>
)}
</div>
))}
</section>
</div>
);
}