feat(projects): select multiple GitHub source repositories (#13010)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Projects give tasks a common source repository and execution
context.
> - The current project form asks for a raw URL and unrelated metadata.
> - Teams need to select several repos from GitHub connections they can
use.
> - This pull request implements the reviewed project form and
repository editor.
> - The server checks credential ownership and shared audiences before
discovery.
> - Existing workspace URLs and runtime identity rules remain
compatible.

## Linked Issues or Issue Description

**Problem or motivation**

Project creation accepts one raw repository URL. It does not help users
select repos from their usable GitHub connections or attach several
repos together.

**Proposed solution**

Add a shared GitHub repository picker to project creation and
Configuration. Support multiple selections, transactional persistence,
and the existing GitHub setup flow. Simplify the project form and
Configuration tab as reviewed.

**Alternatives considered**

Keep a raw URL field or add a separate repository table. The existing
workspace collection already supports several repositories and keeps
legacy URLs compatible.

**Roadmap alignment**

This builds on the shipped MCP Tool Gateway and Apps capability. It does
not change runtime credential delegation.

Related work: #11662 addresses the existing dialog's viewport limits.
#4552 addresses generic Git URLs; this change preserves those URLs in
existing workspaces.

## What Changed

- Add company-scoped repository discovery from usable personal and
shared GitHub grants, with provider-ID deduplication, PAT pagination,
and partial failure handling.
- Document the repository endpoints and board access requirements in
OpenAPI.
- Validate new selections and save projects with multiple repository
workspaces in one transaction. Preserve legacy URLs and existing
selections whose access was lost.
- Implement the reviewed Create project dialog, shared repository
editor, scrolling, and mobile layout.
- Move repositories above environment variables, remove Status and Goals
controls and env help paragraphs, move Created to the bottom, and
redirect Overview to Configuration.
- Reuse GitHub setup in dialogs, preserve project drafts, and verify
popup completion through the API.
- Replace the configuration story's DOM adapter with explicit production
composition. Keep the reviewed mobile and short-viewport stories.

## Verification

- Passed: `pnpm build`, `pnpm -r typecheck`, `pnpm build-storybook`, and
`pnpm check:token-gates`.
- Passed: focused repository access, database persistence,
configuration, and connection setup tests.
- Passed: `pnpm exec playwright test --config
tests/e2e/playwright.config.ts tests/e2e/project-repositories.spec.ts`.
- The browser tests use a real temporary server/database. They cover
create, forty persisted repos, mobile scrolling, save/reload, legacy URL
editing, and rejection without a partial project.
- GitHub responses and popup completion use deterministic fixtures. No
real GitHub account was authorized by the test suite.
- All CI general, serialized server, and browser test shards pass on the
final commit.
- The local full-suite run overlapped review edits and was stopped;
fresh repository, OpenAPI, UI/CLI, and connection tests pass. Unrelated
local worker, built-in-agent, and routine timing/socket failures passed
isolated reruns.
- Final commit `1b3308dca`: all CI gates pass, including build, runner
verification, typecheck, canary dry run, and security checks. Greptile
is 5/5 with no unresolved review threads.
- Storybook visual regression is opt-in and was skipped by CI; the
Storybook build passed locally.

## Risks

- Repository discovery depends on provider availability. Failed
connections are reported while successful results stay usable.
- Selections identify source workspaces; they do not grant agents new
credentials. The existing primary-workspace and responsible-user
identity rules still apply.
- No database migration is needed. Existing API status, goals, dates,
and manual workspace URLs remain supported.

## Model Used

OpenAI Codex, based on GPT-6, with repository inspection, code
execution, and browser tools. The runtime does not expose a more
specific model deployment ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-08 08:21:28 -05:00 committed by GitHub
parent 297d8741f5
commit b97101893f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1428 additions and 875 deletions

View File

@ -216,6 +216,14 @@ Invariant:
Routine execution issues add a routine-scoped env overlay after project env and before Paperclip runtime-owned keys. Routine env uses the same secret-aware binding format, is stored on `routines.env`, is snapshotted in routine revisions, and resolves secret refs against the routine binding target so routine-owned secrets do not require direct bindings on the executing agent.
Project source repositories use the existing `project_workspaces` collection.
Each selected GitHub repository has a canonical `repo_url` and stable provider ID
in `metadata.githubRepositoryId`; the first workspace remains the execution default.
The board can select multiple repositories from its usable personal and shared
GitHub grants. Selection does not grant runtime credential access. Legacy workspace
URLs remain valid. Project creation and repository replacement are transactional.
See `doc/project-repositories.md` for the API and UI contract.
## 7.6 `issues` (core task entity)
- `id` uuid pk

View File

@ -0,0 +1,69 @@
# Project source repositories
The Create project dialog accepts a name and optional GitHub repository selections.
It uses the same `RepositoryEditor` as project Configuration. Description remains
editable in Configuration. Status, goal links, and target dates remain supported by
the API but are omitted from creation; Status and Goals are omitted from Configuration.
Old Overview URLs and saved Overview preferences redirect to Configuration.
## API and persistence
- `GET /api/companies/:companyId/project-repositories` returns `repositories`,
`connectionCount`, and `failedConnectionCount`. Repository IDs are GitHub's stable
numeric IDs represented as strings. Results are deduplicated across accessible
grants and sorted by full name. Connection labels are display provenance only.
- `POST /api/companies/:companyId/projects` accepts optional `repositoryIds`.
The server resolves new selections through the caller's authorized GitHub grants
before creating the project and all repository workspaces in a transaction.
The existing `workspace` input remains supported; it cannot be combined with
`repositoryIds`.
- `PUT /api/projects/:id/repositories` accepts the selected `repositoryIds` array.
Accessible retained IDs refresh their canonical name and URL after renames or
transfers; unavailable retained IDs keep their saved metadata.
Replacement is transactional. Existing selections may be retained or removed even
if their GitHub connection becomes unavailable. New identities require current
access. Legacy URL workspaces are preserved, and matching legacy URLs are adopted
without creating a duplicate workspace. Local/remote workspace locations survive
detaching their repository.
No schema migration is required. Selected repositories are normal project workspaces
with `metadata.githubRepositoryId`. Existing manual `repoUrl` workspaces remain
editable through Configuration and the workspace API. One workspace remains primary;
additional repositories do not change the existing runtime workspace-selection or
responsible-user credential rules. A repository selection never delegates credentials.
## Discovery and setup
The server checks company membership, grant ownership/status, and organization-grant
audiences before loading provider metadata. Connection managers receive no bypass to
another person's personal repositories. Managed GitHub grants refresh installation
access; PAT connections use paginated `/user/repos`. Provider failures are reported
without exposing provider error bodies or credential material. Successful connections
remain selectable when another connection fails.
`ConnectionSetupFlow` owns provider setup in both Apps and project dialogs. Task
intents retain their existing callback protocol. Standalone dialogs verify the saved
connection through the API after the sign-in popup returns to the instance. Project
name and repository drafts stay mounted across setup and cancellation.
## UI review and verification
`Proposals/Project repos` contains the reviewed states, including loading, failure,
empty search, disconnected GitHub, multiple repos, legacy URLs, forty selections,
mobile, and short viewports. The configuration story composes the production page
properties through an explicit repositories slot. Story setup and saves use fixtures.
- Shared visual control: `ui/src/components/RepositoryEditor.tsx`.
- Data and error handling: `ProjectRepositoryInput.tsx`.
- Configuration persistence and legacy editing: `ProjectRepositories.tsx` and
`LegacyProjectRepository.tsx`.
- Production dialog: `NewProjectDialog.tsx`.
- Server tests: `project-repositories.test.ts` and
`project-repositories-persistence.test.ts`.
- Browser acceptance: `tests/e2e/project-repositories.spec.ts`.
The browser suite uses a real temporary server and database. It verifies creation,
forty persisted repos, mobile scrolling, removal/save/reload, legacy URL editing,
and rejection without a partial project. Provider discovery is simulated in the
picker rejection test. GitHub network and popup behavior use deterministic fixtures
in integration/component tests; the suite does not authorize a real GitHub account.

View File

@ -928,6 +928,8 @@ export type {
AssetImage,
Project,
ProjectBudgetSummary,
ProjectRepository,
ProjectRepositoryOptions,
ProjectCodebase,
ProjectCodebaseOrigin,
ProjectGoalRef,

View File

@ -349,7 +349,7 @@ export type {
DocumentTextRange,
UpdateDocumentAnnotationThreadRequest,
} from "./document-annotation.js";
export type { Project, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type { Project, ProjectRepository, ProjectRepositoryOptions, ProjectBudgetSummary, ProjectCodebase, ProjectCodebaseOrigin, ProjectGoalRef, ProjectManagedByPlugin, ProjectWorkspace } from "./project.js";
export type {
CompanySearchCountType,
CompanySearchExtractIssueResult,

View File

@ -112,3 +112,18 @@ export interface Project {
createdAt: Date;
updatedAt: Date;
}
/** GitHub identity is the provider's stable repository ID, never a credential. */
export interface ProjectRepository {
id: string;
fullName: string;
url: string;
private?: boolean;
connections: string[];
}
export interface ProjectRepositoryOptions {
repositories: ProjectRepository[];
connectionCount: number;
failedConnectionCount: number;
}

View File

@ -119,6 +119,7 @@ const projectFields = {
export const createProjectSchema = z.object({
...projectFields,
workspace: createProjectWorkspaceSchema.optional(),
repositoryIds: z.array(z.string().regex(/^\d+$/)).optional(),
});
export type CreateProject = z.infer<typeof createProjectSchema>;

View File

@ -280,6 +280,18 @@ describe("openapi routes", () => {
});
});
it("documents board-only repository discovery and selection", () => {
const { spec } = loadSpecRoutes();
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
const replacement = spec.paths["/api/projects/{id}/repositories"].put;
for (const operation of [discovery, replacement]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(replacement.requestBody.content["application/json"].schema.required).toContain("repositoryIds");
expect(replacement.responses["422"]).toBeDefined();
});
it("documents auth and reviewed response-code invariants", () => {
const { spec } = loadSpecRoutes();

View File

@ -0,0 +1,115 @@
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { companies, companyMemberships, companySecrets, connectionGrants, connectionGrantMembers, toolApplications, toolConnections, createDb, projects as projectTable } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { toolAccessService } from "../services/tool-access.js";
import { projectService } from "../services/projects.js";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
vi.mock("../services/secrets.js", () => ({ secretService: () => ({
resolveSecretValue: async (_companyId: string, secretId: string) => secretId,
}) }));
const support = await getEmbeddedPostgresTestSupport();
(support.supported ? describe : describe.skip)("project repository persistence", () => {
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
let db: ReturnType<typeof createDb>;
let companyId: string;
const repo = (id: string) => ({ id, fullName: `org/repo-${id}`, url: `https://github.com/org/repo-${id}`, connections: [] });
beforeAll(async () => {
temp = await startEmbeddedPostgresTestDatabase("paperclip-repositories-");
db = createDb(temp.connectionString);
[companyId] = (await db.insert(companies).values({ name: "Repositories", issuePrefix: "REPO" }).returning()).map((company) => company.id);
}, 20_000);
afterAll(async () => { await temp?.cleanup(); });
afterEach(() => vi.unstubAllGlobals());
it("creates multiple source repos atomically and persists them across reads", async () => {
const svc = projectService(db);
const created = await svc.createWithRepositories(companyId, { name: "Multi" }, [repo("1"), repo("2")]);
const fetched = await svc.getById(created.id);
expect(fetched?.workspaces.map((workspace) => workspace.repoUrl).sort()).toEqual([repo("1").url, repo("2").url]);
expect(fetched?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
expect(fetched?.workspaces.every((workspace) => workspace.companyId === companyId)).toBe(true);
});
it("adds/removes repos, promotes the remaining primary, and preserves legacy and local config", async () => {
const svc = projectService(db);
const created = await svc.createWithRepositories(companyId, { name: "Edit" }, [repo("3"), repo("4")]);
const legacy = await svc.createWorkspace(created.id, { repoUrl: "https://git.example/legacy/repo" });
const local = await svc.createWorkspace(created.id, { cwd: "/tmp/local-project", repoUrl: repo("5").url, metadata: { githubRepositoryId: "5", retained: true } });
const updated = await svc.replaceRepositories(created.id, [repo("4"), repo("6")]);
expect(updated?.workspaces.find((workspace) => workspace.id === legacy!.id)?.repoUrl).toBe("https://git.example/legacy/repo");
expect(updated?.workspaces.find((workspace) => workspace.id === local!.id)).toMatchObject({ cwd: "/tmp/local-project", repoUrl: null, metadata: { retained: true } });
expect(updated?.workspaces.some((workspace) => workspace.repoUrl === repo("3").url)).toBe(false);
expect(updated?.workspaces.filter((workspace) => workspace.isPrimary)).toHaveLength(1);
const repeated = await svc.replaceRepositories(created.id, [repo("4"), repo("6")]);
expect(repeated?.workspaces).toHaveLength(updated!.workspaces.length);
});
it("handles a repo recreated at the same URL with a new GitHub identity", async () => {
const svc = projectService(db);
const original = repo("20");
const created = await svc.createWithRepositories(companyId, { name: "Recreated" }, [original]);
const updated = await svc.replaceRepositories(created.id, [{ ...original, id: "21" }]);
expect(updated?.workspaces).toHaveLength(1);
expect(updated?.workspaces[0]?.metadata?.githubRepositoryId).toBe("21");
});
it("refreshes renamed and transferred repositories without replacing workspace identity or configuration", async () => {
const svc = projectService(db);
const created = await svc.createWithRepositories(companyId, { name: "Renamed" }, [repo("22")]);
const workspace = created.workspaces[0];
await svc.updateWorkspace(created.id, workspace.id, { cwd: "/tmp/renamed-project", repoRef: "release", metadata: { ...workspace.metadata, retained: true } });
const renamed = { ...repo("22"), fullName: "new-owner/new-name", url: "https://github.com/new-owner/new-name" };
const updated = await svc.replaceRepositories(created.id, [renamed]);
expect(updated?.workspaces).toHaveLength(1);
expect(updated?.workspaces[0]).toMatchObject({ id: workspace.id, name: renamed.fullName, repoUrl: renamed.url, cwd: "/tmp/renamed-project", repoRef: "release", isPrimary: true, metadata: { githubRepositoryId: "22", retained: true } });
expect(updated?.codebase.repoUrl).toBe(renamed.url);
});
it("loads only usable connection grants, deduplicates repos, and reports partial provider failures", async () => {
await db.insert(companyMemberships).values({ companyId, principalType: "user", principalId: "alice", membershipRole: "admin" });
const [otherCompany] = await db.insert(companies).values({ name: "Other", issuePrefix: "OTHER" }).returning();
const tokenRepos = new Map<string, Array<{ id: number; full_name: string }>>();
async function connection(name: string, kind: "user" | "organization", owner: string | null, audience: string[] = [], targetCompanyId = companyId) {
const [app] = await db.insert(toolApplications).values({ companyId: targetCompanyId, name, type: "mcp_http" }).returning();
const [secret] = await db.insert(companySecrets).values({ companyId: targetCompanyId, name, key: name }).returning();
const [conn] = await db.insert(toolConnections).values({ companyId: targetCompanyId, applicationId: app.id, name, uid: name, status: "active", enabled: true,
transport: "mcp_remote", authKind: "api_key", credentialPolicy: kind === "user" ? "per_user" : "shared", config: { sourceTemplateKey: "github" } }).returning();
const [grant] = await db.insert(connectionGrants).values({ companyId: targetCompanyId, connectionId: conn.id, kind, subjectUserId: owner,
credentialSecretRefs: [{ secretId: secret.id, configPath: "credentials.authorization", versionSelector: "latest" }] }).returning();
for (const subjectId of audience) await db.insert(connectionGrantMembers).values({ companyId: targetCompanyId, grantId: grant.id, subjectType: "user", subjectId });
return secret.id;
}
const personal = await connection("personal", "user", "alice");
const shared = await connection("shared", "organization", null);
const otherPerson = await connection("other-person", "user", "bob");
const restricted = await connection("restricted", "organization", null, ["bob"]);
const crossCompany = await connection("cross-company", "organization", null, [], otherCompany.id);
const broken = await connection("broken", "organization", null);
tokenRepos.set(personal, [{ id: 10, full_name: "org/common" }, { id: 11, full_name: "alice/private" }]);
tokenRepos.set(shared, [{ id: 10, full_name: "org/common" }, { id: 12, full_name: "org/shared" }]);
const request = vi.fn<typeof fetch>(async (_url, init) => {
const token = new Headers(init?.headers).get("authorization")?.replace("Bearer ", "") ?? "";
if (token === broken) return new Response("secret-provider-error", { status: 503 });
expect([otherPerson, restricted, crossCompany]).not.toContain(token);
return Response.json(tokenRepos.get(token) ?? []);
});
vi.stubGlobal("fetch", request);
const result = await toolAccessService(db).listProjectRepositories(companyId, "alice");
expect(result.connectionCount).toBe(3);
expect(result.failedConnectionCount).toBe(1);
expect(result.repositories.map((repo) => repo.id).sort()).toEqual(["10", "11", "12"]);
expect(result.repositories.find((repo) => repo.id === "10")?.connections.sort()).toEqual(["personal", "shared"]);
expect(JSON.stringify(result)).not.toContain("secret-provider-error");
expect(request).toHaveBeenCalledTimes(3);
const revokedMembership = await db.update(companyMemberships).set({ status: "inactive" }).where(eq(companyMemberships.principalId, "alice")).returning();
expect(revokedMembership).toHaveLength(1);
expect((await toolAccessService(db).listProjectRepositories(companyId, "alice")).repositories).toEqual([]);
expect(request).toHaveBeenCalledTimes(3);
});
it("rolls back the project if a later repository insert fails", async () => {
const svc = projectService(db);
await expect(svc.createWithRepositories(companyId, { name: "Rollback" }, [repo("7"), { ...repo("8"), fullName: "invalid" + String.fromCharCode(0) + "name" }])).rejects.toThrow();
expect(await db.select().from(projectTable).where(eq(projectTable.name, "Rollback"))).toEqual([]);
});
});

View File

@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { canBrowseProjectRepositoryGrant, mergeProjectRepository, resolveProjectRepositorySelection } from "../services/project-repositories.js";
import { loadGitHubTokenRepositories } from "../services/tool-access.js";
import type { ProjectRepository } from "@paperclipai/shared";
describe("project repository access", () => {
const own = { status: "active", kind: "user", subjectUserId: "alice" };
const shared = { ...own, kind: "organization", subjectUserId: null };
const allowed = (grant: { status: string; kind: string; subjectUserId: string | null } = own, userId: string | null = "alice", activeMember = true, audience: string[] = []) =>
canBrowseProjectRepositoryGrant({ grant, userId, activeMember, audience });
it("includes only the caller's personal identity and active company membership", () => {
expect(allowed()).toBe(true);
expect(allowed(own, "bob")).toBe(false);
expect(allowed(own, null)).toBe(false);
expect(allowed(own, "alice", false)).toBe(false);
expect(allowed({ ...own, kind: "agent" })).toBe(false);
for (const status of ["revoked", "expired", "needs_reauthorization"]) expect(allowed({ ...own, status })).toBe(false);
});
it("honors shared audiences without an administrator bypass", () => {
expect(allowed(shared)).toBe(true);
expect(allowed(shared, "alice", true, ["alice"])).toBe(true);
expect(allowed(shared, "alice", true, ["bob"])).toBe(false);
expect(allowed(shared, "alice", false)).toBe(false);
expect(allowed(shared, null, false)).toBe(true); // local trusted board
expect(allowed(shared, null, false, ["alice"])).toBe(false);
});
it("deduplicates by provider id across personal and shared connections", () => {
const repos = new Map<string, ProjectRepository>();
mergeProjectRepository(repos, { id: "10", fullName: "org/old", private: true }, "Personal");
mergeProjectRepository(repos, { id: "10", fullName: "org/renamed", private: true }, "Company");
mergeProjectRepository(repos, { id: "10", fullName: "org/renamed", private: true }, "Company");
expect([...repos.values()]).toEqual([{ id: "10", fullName: "org/renamed", url: "https://github.com/org/renamed", private: true, connections: ["Personal", "Company"] }]);
});
it("refreshes retained selections from discovery, falls back only for unavailable existing IDs, and rejects new unavailable IDs", () => {
const existing = [{ name: "old/name", repoUrl: "https://github.com/old/name", metadata: { githubRepositoryId: "1" } }];
const renamed = { id: "1", fullName: "new/name", url: "https://github.com/new/name", connections: ["Personal"] };
expect(resolveProjectRepositorySelection(["1", "1"], [renamed], existing)).toEqual([renamed]);
expect(resolveProjectRepositorySelection(["1"], [], existing)).toEqual([{ id: "1", fullName: "old/name", url: existing[0].repoUrl, connections: [] }]);
expect(() => resolveProjectRepositorySelection(["2"], [], existing)).toThrow("no longer available");
});
it("loads every PAT repository page and never follows provider-supplied URLs", async () => {
const request = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(JSON.stringify([{ id: 1, full_name: "org/a" }]), { headers: { link: '<https://evil.test/steal>; rel="next"' } }))
.mockResolvedValueOnce(new Response(JSON.stringify([{ id: 2, full_name: "org/b", private: true }])));
expect(await loadGitHubTokenRepositories({ Authorization: "Bearer fixture" }, request)).toEqual([{ id: "1", fullName: "org/a" }, { id: "2", fullName: "org/b", private: true }]);
expect(request.mock.calls.map(([url]) => String(url))).toEqual([
"https://api.github.com/user/repos?per_page=100&page=1", "https://api.github.com/user/repos?per_page=100&page=2",
]);
});
it("surfaces failed or invalid provider responses without exposing their body", async () => {
for (const response of [new Response("secret", { status: 401 }), new Response(JSON.stringify([{ id: 1, full_name: "../bad" }]))]) {
await expect(loadGitHubTokenRepositories({}, vi.fn().mockResolvedValue(response))).rejects.toThrow(/GitHub/);
}
});
});

View File

@ -858,6 +858,8 @@ const BOARD_ONLY_PREFIXES = [
];
const BOARD_ONLY_OPERATIONS = new Set([
"GET /api/companies/{companyId}/project-repositories",
"PUT /api/projects/{id}/repositories",
"DELETE /api/issues/{id}/documents/{key}",
"GET /api/companies/{companyId}/decisions",
"GET /api/cloud/stacks",
@ -2830,6 +2832,33 @@ registry.registerPath({
// ─── Projects ────────────────────────────────────────────────────────────────
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/project-repositories",
tags: ["projects"],
summary: "Discover GitHub repositories available to the current board user",
description: "Deduplicates repositories across usable personal and company-shared GitHub connections. Failed connections are reported without discarding successful results.",
request: { params: z.object({ companyId: z.string() }) },
responses: {
200: r.ok(z.object({
repositories: z.array(z.object({ id: z.string(), fullName: z.string(), url: z.string(), private: z.boolean().optional(), connections: z.array(z.string()) })),
connectionCount: z.number().int().nonnegative(),
failedConnectionCount: z.number().int().nonnegative(),
})),
401: r.unauthorized, 403: r.forbidden,
},
});
registry.registerPath({
method: "put",
path: "/api/projects/{id}/repositories",
tags: ["projects"],
summary: "Replace selected GitHub source repositories",
description: "Saves provider IDs transactionally, refreshes canonical names and URLs, and preserves legacy workspace URLs. Unavailable existing selections can remain; new selections must be available to the caller.",
request: { params: z.object({ id: z.string() }), body: jsonBody(createProjectSchema.pick({ repositoryIds: true }).required()) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 422: r.unprocessable },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/projects",
@ -2844,11 +2873,12 @@ registry.registerPath({
path: "/api/companies/{companyId}/projects",
tags: ["projects"],
summary: "Create a project",
description: "The optional repositoryIds field selects GitHub source repositories and requires a board caller. It cannot be combined with workspace. All selections are validated before the project and repository workspaces are created atomically.",
request: {
params: z.object({ companyId: z.string() }),
body: jsonBody(createProjectSchema),
},
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable },
});
registry.registerPath({

View File

@ -1,3 +1,6 @@
import { z } from "zod";
import { resolveProjectRepositorySelection } from "../services/project-repositories.js";
import { toolAccessService } from "../services/tool-access.js";
import { Router, type Request, type Response } from "express";
import type { Db } from "@paperclipai/db";
import {
@ -17,7 +20,7 @@ import { accessService, projectService, logActivity, workspaceOperationService }
import { conflict, forbidden, unprocessable } from "../errors.js";
import { externalObjectService } from "../services/external-objects.js";
import { instanceSettingsService } from "../services/instance-settings.js";
import { assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import { assertBoard, assertCompanyAccess, getAccessibleResource, getActorInfo } from "./authz.js";
import {
buildWorkspaceRuntimeDesiredStatePatch,
listConfiguredRuntimeServiceEntries,
@ -43,6 +46,13 @@ const SHARED_WORKSPACE_STOP_AND_RESTART_ACTIONS = new Set(["stop", "restart"]);
export function projectRoutes(db: Db) {
const router = Router();
const svc = projectService(db);
async function selectedRepositories(req: Request, companyId: string, ids: string[], existing: import("@paperclipai/shared").ProjectWorkspace[] = []) {
assertBoard(req);
if (!ids.length) return [];
const available = await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit");
return resolveProjectRepositorySelection(ids, available.repositories, existing);
}
const access = accessService(db);
const secretsSvc = secretService(db);
const workspaceOperations = workspaceOperationService(db);
@ -162,6 +172,28 @@ export function projectRoutes(db: Db) {
}
});
router.get("/companies/:companyId/project-repositories", async (req, res) => {
assertBoard(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await toolAccessService(db).listProjectRepositories(companyId, req.actor.userId ?? null, req.actor.source === "local_implicit"));
});
router.put("/projects/:id/repositories", validate(z.object({ repositoryIds: z.array(z.string().regex(/^\d+$/)) })), async (req, res) => {
assertBoard(req);
const project = await getAccessibleResource(req, res, svc.getById(req.params.id as string), "Project not found");
if (!project) return;
const repositories = await selectedRepositories(req, project.companyId, req.body.repositoryIds, project.workspaces);
const updated = await svc.replaceRepositories(project.id, repositories);
const actor = getActorInfo(req);
await logActivity(db, {
companyId: project.companyId, actorType: actor.actorType, actorId: actor.actorId,
action: "project.repositories_updated", entityType: "project", entityId: project.id,
details: { repositoryIds: repositories.map((repo) => repo.id) },
});
res.json(updated);
});
router.get("/companies/:companyId/projects", async (req, res) => {
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
@ -191,9 +223,10 @@ export function projectRoutes(db: Db) {
assertCompanyAccess(req, companyId);
type CreateProjectPayload = Parameters<typeof svc.create>[1] & {
workspace?: Parameters<typeof svc.createWorkspace>[1];
repositoryIds?: string[];
};
const { workspace, ...projectData } = req.body as CreateProjectPayload;
const { workspace, repositoryIds, ...projectData } = req.body as CreateProjectPayload;
await assertProjectEnvironmentSelection(
companyId,
readProjectPolicyEnvironmentId(projectData.executionWorkspacePolicy),
@ -213,7 +246,9 @@ export function projectRoutes(db: Db) {
{ strictMode: strictSecretsMode, fieldPath: "env" },
);
}
const project = await svc.create(companyId, projectData);
if (workspace && repositoryIds) throw unprocessable("Use either workspace or repositoryIds when creating a project");
const repositories = repositoryIds ? await selectedRepositories(req, companyId, repositoryIds) : null;
const project = repositories ? await svc.createWithRepositories(companyId, projectData, repositories) : await svc.create(companyId, projectData);
if (project.env) {
await secretsSvc.syncEnvBindingsForTarget?.(
companyId,

View File

@ -0,0 +1,42 @@
import type { ProjectRepository, ProjectWorkspace } from "@paperclipai/shared";
import { unprocessable } from "../errors.js";
import { isConnectionGrantAudienceAllowed } from "./tool-gateway.js";
export function canBrowseProjectRepositoryGrant(input: {
grant: { status: string; kind: string; subjectUserId: string | null };
userId: string | null;
activeMember: boolean;
audience: string[];
}) {
const { grant, userId, activeMember, audience } = input;
if (grant.status !== "active") return false;
if (grant.kind === "user") return Boolean(userId && activeMember && grant.subjectUserId === userId);
return grant.kind === "organization" && isConnectionGrantAudienceAllowed(audience, userId, activeMember);
}
export function mergeProjectRepository(
repositories: Map<string, ProjectRepository>,
repo: { id: string; fullName: string; private?: boolean },
connectionName: string,
) {
const previous = repositories.get(repo.id);
repositories.set(repo.id, {
...repo, url: `https://github.com/${repo.fullName}`,
connections: [...new Set([...(previous?.connections ?? []), connectionName])],
});
}
/** Prefer current provider metadata; unavailable existing selections can remain. */
export function resolveProjectRepositorySelection(
ids: string[],
available: ProjectRepository[],
existing: Pick<ProjectWorkspace, "name" | "repoUrl" | "metadata">[] = [],
): ProjectRepository[] {
return [...new Set(ids)].map((id) => {
const current = available.find((repo) => repo.id === id);
if (current) return current;
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === id && workspace.repoUrl);
if (retained) return { id, fullName: retained.name, url: retained.repoUrl!, connections: [] };
throw unprocessable("A selected GitHub repository is no longer available. Refresh repositories and try again.");
});
}

View File

@ -811,6 +811,52 @@ export function projectService(db: Db) {
};
},
createWithRepositories: async (companyId: string, data: Parameters<typeof createProject>[1], repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals> => {
return db.transaction(async (tx) => {
const service = projectService(tx as unknown as Db);
const project = await service.create(companyId, data);
for (const repo of repositories) {
await service.createWorkspace(project.id, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
}
return (await service.getById(project.id))!;
});
},
replaceRepositories: async (projectId: string, repositories: import("@paperclipai/shared").ProjectRepository[]): Promise<ProjectWithGoals | null> => {
return db.transaction(async (tx) => {
const [project] = await tx.select().from(projects).where(eq(projects.id, projectId)).for("update");
if (!project) return null;
const service = projectService(tx as unknown as Db);
const existing = await service.listWorkspaces(projectId);
const ids = new Set(repositories.map((repo) => repo.id));
for (const workspace of existing) {
const repoId = workspace.metadata?.githubRepositoryId;
if (typeof repoId === "string" && !ids.has(repoId)) {
// Keep local/runtime workspace configuration when detaching source.
if (workspace.cwd || workspace.remoteWorkspaceRef) {
const { githubRepositoryId: _id, ...metadata } = workspace.metadata!;
await service.updateWorkspace(projectId, workspace.id, { repoUrl: null, metadata });
} else await service.removeWorkspace(projectId, workspace.id);
}
}
for (const repo of repositories) {
const retained = existing.find((workspace) => workspace.metadata?.githubRepositoryId === repo.id);
if (retained) {
if (retained.repoUrl !== repo.url || retained.name !== repo.fullName) {
await service.updateWorkspace(projectId, retained.id, { name: repo.fullName, repoUrl: repo.url });
}
continue;
}
const legacy = existing.find((workspace) => !workspace.metadata?.githubRepositoryId && workspace.repoUrl?.replace(/\.git$/, "").replace(/\/$/, "").toLowerCase() === repo.url.toLowerCase());
if (legacy) {
await service.updateWorkspace(projectId, legacy.id, { metadata: { ...legacy.metadata, githubRepositoryId: repo.id } });
} else await service.createWorkspace(projectId, { name: repo.fullName, repoUrl: repo.url, metadata: { githubRepositoryId: repo.id } });
}
await tx.update(projects).set({ updatedAt: new Date() }).where(eq(projects.id, projectId));
return service.getById(projectId);
});
},
create: createProject,
update: async (

View File

@ -1,3 +1,4 @@
import { canBrowseProjectRepositoryGrant, mergeProjectRepository } from "./project-repositories.js";
import { captureRunIdentity } from "./run-identity.js";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
@ -1930,6 +1931,27 @@ function managedConnectorProfile(value: string | undefined): {
return null;
}
export async function loadGitHubTokenRepositories(headers: Record<string, string>, request: typeof fetch = fetch) {
const repositories: Array<{ id: string; fullName: string; private?: boolean }> = [];
for (let page = 1; ; page += 1) {
const response = await request(`https://api.github.com/user/repos?per_page=100&page=${page}`, {
headers: { ...headers, accept: "application/vnd.github+json", "user-agent": "Paperclip", "x-github-api-version": "2022-11-28" },
signal: AbortSignal.timeout(15_000),
});
if (!response.ok) throw unprocessable("Could not load GitHub repositories. Reconnect GitHub and try again.");
const rows: unknown = await response.json();
if (!Array.isArray(rows)) throw unprocessable("GitHub returned invalid repositories");
for (const row of rows) {
if (!recordValue(row) || !githubId(row.id) || typeof row.full_name !== "string" || !/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(row.full_name)) {
throw unprocessable("GitHub returned invalid repository metadata");
}
repositories.push({ id: githubId(row.id)!, fullName: row.full_name, ...(typeof row.private === "boolean" ? { private: row.private } : {}) });
}
if (!/;\s*rel="next"/.test(response.headers.get("link") ?? "")) return repositories;
if (!rows.length) throw unprocessable("GitHub returned invalid pagination");
}
}
export async function loadGitHubGrantMetadata(
accessToken: string,
request: typeof fetch = fetch,
@ -2014,7 +2036,7 @@ export async function loadGitHubGrantMetadata(
for (const repository of await list(`/user/installations/${installationId}/repositories`, "repositories")) {
const id = githubId(repository.id);
const fullName = typeof repository.full_name === "string" ? repository.full_name : "";
if (!id || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(fullName)) {
if (!id || !/^[A-Za-z0-9][A-Za-z0-9-]*\/(?!\.{1,2}$)[A-Za-z0-9_.-]+$/.test(fullName)) {
throw unprocessable("GitHub returned invalid repository metadata", { code: "github_bad_response" });
}
repositories.set(id, {
@ -12374,6 +12396,70 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
return toApplication(row);
},
// Repository discovery uses credential audiences, not connection-management
// visibility. An administrator cannot browse another user's private repos.
listProjectRepositories: async (companyId: string, userId: string | null, localTrusted = false) => {
const [connections, grants, members, memberships] = await Promise.all([
db.select().from(toolConnections).where(and(eq(toolConnections.companyId, companyId), eq(toolConnections.enabled, true))),
db.select().from(connectionGrants).where(eq(connectionGrants.companyId, companyId)),
db.select().from(connectionGrantMembers).where(eq(connectionGrantMembers.companyId, companyId)),
userId ? db.select().from(companyMemberships).where(and(
eq(companyMemberships.companyId, companyId), eq(companyMemberships.principalType, "user"),
eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"),
)) : Promise.resolve([]),
]);
const repositories = new Map<string, import("@paperclipai/shared").ProjectRepository>();
let connectionCount = 0;
let failedConnectionCount = 0;
for (const connection of connections) {
if (connection.status !== "active" || asRecord(connection.config).sourceTemplateKey !== "github") continue;
const connectionGrants = grants.filter((grant) => grant.connectionId === connection.id);
const availableGrants = connectionGrants.filter((grant) =>
!(grant.kind === "organization" && ["per_user", "per_agent"].includes(connection.credentialPolicy))
&& canBrowseProjectRepositoryGrant({
grant, userId, activeMember: localTrusted || memberships.length > 0,
audience: members.filter((member) => member.grantId === grant.id).map((member) => member.subjectId),
}));
// Legacy shared PAT connections predate grants. Never fall back when a
// grant exists but is revoked, private, or outside the caller's audience.
const legacyShared = connectionGrants.length === 0 && connection.credentialPolicy === "shared"
&& (localTrusted || !!userId && memberships.length > 0);
if (!availableGrants.length && !legacyShared) continue;
connectionCount += 1;
const actor: ActorInfo = { actorType: "user", actorId: userId ?? "board" };
let failed = false;
for (const initialGrant of legacyShared ? [null] : availableGrants) {
try {
let rows: Array<{ id: string; fullName: string; private?: boolean }>;
if (initialGrant && asRecord(asRecord(connection.config).oauth).connectorProfile === "github.code") {
const grant = await refreshManagedGitHubGrantAccess(connection, initialGrant, actor);
rows = grant.providerTenant?.github?.repositories ?? [];
} else {
const headers = initialGrant
? await (async () => {
const ref = initialGrant.credentialSecretRefs.find((ref) =>
ref.configPath === "oauth.access_token" || /authorization|token|api_key/i.test(ref.configPath));
if (!ref) throw unprocessable("Reconnect GitHub to load repositories");
const secret = await resolveOAuthGrantSecret(connection, initialGrant, ref, actor, undefined);
return { Authorization: `Bearer ${secret.value}` };
})()
: await resolveCredentialHeaders(connection, actor);
rows = await loadGitHubTokenRepositories(headers);
}
for (const row of rows) {
mergeProjectRepository(repositories, row, connection.name);
}
} catch {
// Credential/provider errors may contain secrets. Only expose an
// aggregate failure; successful connections remain usable.
failed = true;
}
}
if (failed) failedConnectionCount += 1;
}
return { repositories: [...repositories.values()].sort((a, b) => a.fullName.localeCompare(b.fullName)), connectionCount, failedConnectionCount };
},
listConnections: async (companyId: string): Promise<ToolConnection[]> => {
const rows = await db
.select()

View File

@ -0,0 +1,84 @@
import { expect, test } from "@playwright/test";
test("project creation and repository configuration persist on a short mobile viewport", async ({ page }) => {
test.setTimeout(120_000);
const createdCompany = await page.request.post("/api/companies", { data: { name: "Repository UX" } });
expect(createdCompany.ok()).toBe(true);
const company = await createdCompany.json();
await page.goto(`/${company.issuePrefix}/projects`);
await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("textbox", { name: "Project name" })).toBeFocused();
await dialog.getByRole("textbox", { name: "Project name" }).fill("Repository acceptance");
await expect(dialog.getByText("Source repos", { exact: true })).toBeVisible();
await expect(dialog.getByText("Goal", { exact: true })).toHaveCount(0);
await expect(dialog.getByPlaceholder("https://github.com/org/repo")).toHaveCount(0);
await dialog.getByRole("button", { name: "Create project", exact: true }).click();
await expect(dialog).toHaveCount(0);
const projects = await (await page.request.get(`/api/companies/${company.id}/projects`)).json();
const project = projects.find((item: { name: string }) => item.name === "Repository acceptance");
expect(project).toBeTruthy();
// Seed existing selections through the real workspace API. Provider discovery
// and create authorization are separately covered by server integration tests.
for (let index = 1; index <= 40; index += 1) {
const response = await page.request.post(`/api/projects/${project.id}/workspaces`, { data: {
name: `org/repo-${index}`, repoUrl: `https://github.com/org/repo-${index}`, metadata: { githubRepositoryId: String(index) },
} });
expect(response.ok()).toBe(true);
}
const legacy = await page.request.post(`/api/projects/${project.id}/workspaces`, { data: { name: "Legacy", repoUrl: "https://git.example/org/legacy" } });
expect(legacy.ok()).toBe(true);
await page.setViewportSize({ width: 390, height: 420 });
await page.goto(`/${company.issuePrefix}/projects/${project.id}/overview`);
await expect(page).toHaveURL(/\/configuration$/);
await expect(page.getByRole("tab", { name: "Overview", exact: true })).toHaveCount(0);
const section = page.getByRole("region", { name: "Repositories", exact: true });
await expect(section.getByText("org/repo-40", { exact: true })).toBeVisible();
await section.getByRole("button", { name: "Remove org/repo-40", exact: true }).click();
await section.getByRole("button", { name: "Save changes", exact: true }).click();
await expect(section.getByRole("status")).toHaveText("Changes saved");
await expect(section.getByText("org/repo-40", { exact: true })).toHaveCount(0);
await page.reload();
await expect(section.getByText("org/repo-40", { exact: true })).toHaveCount(0);
await expect(section.getByText("org/repo-39", { exact: true })).toBeVisible();
await section.getByRole("button", { name: "Edit", exact: true }).click();
await section.getByRole("textbox", { name: "Existing repo URL" }).fill("https://git.example/org/updated");
await section.getByRole("button", { name: "Save URL" }).click();
await page.reload();
await expect(section.getByText("https://git.example/org/updated", { exact: true })).toBeVisible();
await expect(page.getByText("Set the KEY to the env var name", { exact: false })).toHaveCount(0);
await expect(page.getByText("Applied to all runs for tasks in this project.", { exact: false })).toHaveCount(0);
const created = page.getByText("Created", { exact: true });
await created.scrollIntoViewIfNeeded();
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true);
const persisted = await (await page.request.get(`/api/projects/${project.id}`)).json();
expect(persisted.workspaces.filter((workspace: { metadata?: { githubRepositoryId?: string } }) => workspace.metadata?.githubRepositoryId)).toHaveLength(39);
});
test("a short new-project dialog keeps actions visible and rejects unavailable selections without a partial project", async ({ page }) => {
const company = await (await page.request.post("/api/companies", { data: { name: "Repository picker" } })).json();
const repos = Array.from({ length: 80 }, (_, index) => ({ id: String(index + 1), fullName: `org/repo-${index + 1}`, url: `https://github.com/org/repo-${index + 1}`, connections: ["Fixture account"], private: true }));
// Only discovery is simulated. Submission reaches the real authorization
// boundary and must reject these unavailable provider identities atomically.
await page.route(`**/api/companies/${company.id}/project-repositories`, (route) => route.fulfill({ json: { repositories: repos, connectionCount: 1, failedConnectionCount: 0 } }));
await page.setViewportSize({ width: 390, height: 420 });
await page.goto(`/${company.issuePrefix}/projects`);
await page.getByRole("button", { name: "Add Project", exact: true }).first().click();
const dialog = page.getByRole("dialog");
await dialog.getByRole("textbox", { name: "Project name" }).fill("Rejected draft");
await dialog.getByRole("button", { name: "Add GitHub repo", exact: true }).click();
await page.getByPlaceholder("Search GitHub repos…").fill("org/repo-80");
await page.getByRole("option").filter({ hasText: "org/repo-80" }).click();
await dialog.getByRole("button", { name: "Add another repo", exact: true }).click();
await page.getByPlaceholder("Search GitHub repos…").fill("org/repo-79");
await page.getByRole("option").filter({ hasText: "org/repo-79" }).click();
const submit = dialog.getByRole("button", { name: "Create project", exact: true });
const bounds = await submit.boundingBox();
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(420);
await submit.click();
await expect(dialog.getByRole("alert")).toContainText("no longer available");
await expect(dialog.getByRole("textbox", { name: "Project name" })).toHaveValue("Rejected draft");
await expect(dialog.getByText("org/repo-80", { exact: true })).toBeVisible();
const projects = await (await page.request.get(`/api/companies/${company.id}/projects`)).json();
expect(projects).toHaveLength(0);
});

View File

@ -1,5 +1,6 @@
import type {
Project,
ProjectRepositoryOptions,
ProjectWorkspace,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
@ -18,6 +19,8 @@ function projectPath(id: string, companyId?: string, suffix = "") {
}
export const projectsApi = {
repositoryOptions: (companyId: string) => api.get<ProjectRepositoryOptions>(`/companies/${companyId}/project-repositories`),
setRepositories: (id: string, repositoryIds: string[]) => api.put<Project>(projectPath(id, undefined, "/repositories"), { repositoryIds }),
list: (companyId: string, opts: { includeArchived?: boolean } = {}) => {
const params = new URLSearchParams();
if (opts.includeArchived) params.set("includeArchived", "true");

View File

@ -0,0 +1,39 @@
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectWorkspace } from "@paperclipai/shared";
import { Link } from "lucide-react";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
/** Preserve manual URL editing for workspaces created before the GitHub picker. */
export function LegacyProjectRepository({ workspace, projectRef }: { workspace: ProjectWorkspace; projectRef: string }) {
const client = useQueryClient();
const [draft, setDraft] = useState<string | null>(null);
const save = useMutation({
mutationFn: async () => {
const repoUrl = draft?.trim() || null;
if (!repoUrl && !workspace.cwd && !workspace.remoteWorkspaceRef) return projectsApi.removeWorkspace(workspace.projectId, workspace.id);
return projectsApi.updateWorkspace(workspace.projectId, workspace.id, { repoUrl });
},
onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.projects.all(workspace.companyId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(workspace.projectId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(projectRef) });
setDraft(null);
},
});
return <div className="flex min-w-0 flex-col gap-2">
<span className="text-xs text-muted-foreground">Existing repo URL</span>
{draft === null ? <div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<Link className="size-4 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1 break-all text-sm">{workspace.repoUrl}</span>
<Button type="button" variant="ghost" size="sm" onClick={() => setDraft(workspace.repoUrl ?? "")}>Edit</Button>
</div> : <form className="flex flex-col gap-2" onSubmit={(event) => { event.preventDefault(); if (!save.isPending) save.mutate(); }}>
<Input aria-label="Existing repo URL" type="url" value={draft} disabled={save.isPending} onChange={(event) => setDraft(event.target.value)} />
{save.isError && <p role="alert" className="text-sm text-destructive">{save.error.message}</p>}
<div className="flex justify-end gap-2"><Button type="button" variant="ghost" disabled={save.isPending} onClick={() => setDraft(null)}>Cancel</Button><Button type="submit" disabled={save.isPending}>Save URL</Button></div>
</form>}
</div>;
}

View File

@ -16,7 +16,10 @@ function act(callback: () => void) {
});
}
vi.mock("../api/projects", () => ({ projectsApi: { create: vi.fn(), createWorkspace: vi.fn() } }));
vi.mock("../api/projects", () => ({ projectsApi: {
create: vi.fn(),
repositoryOptions: vi.fn().mockResolvedValue({ repositories: [], connectionCount: 0, failedConnectionCount: 0 }),
} }));
vi.mock("../api/goals", () => ({ goalsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([]) } }));
vi.mock("../api/access", () => ({ accessApi: { listUserDirectory: vi.fn().mockResolvedValue({ users: [] }) } }));
@ -80,38 +83,23 @@ function localPathInput() {
return document.body.querySelector('input[placeholder="/absolute/path/to/workspace"]');
}
describe("NewProjectDialog — local folder under the managed-sandbox-only policy", () => {
it("offers the local folder field and its picker when the policy is off", () => {
render({});
expect(documentText()).toContain("Local folder");
expect(localPathInput()).not.toBeNull();
const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Choose",
);
expect(chooseButtons.length).toBeGreaterThan(0);
});
it("keeps the local folder field hidden while the policy is still loading", () => {
// A cold cache resolves the policy to false on the first render. The guard
// fails closed so a managed instance never flashes the field.
render(null);
expect(documentText()).not.toContain("Local folder");
expect(localPathInput()).toBeNull();
expect(documentText()).toContain("Repo URL");
});
it("hides the local folder field and its picker when the policy is on", () => {
render({ enableManagedSandboxOnly: true });
describe("NewProjectDialog — source repositories across host policies", () => {
it.each([
["policy off", {}],
["policy unresolved", null],
["managed sandbox only", { enableManagedSandboxOnly: true }],
] as const)("offers the simplified project form with %s", (_label, settings) => {
render(settings);
expect(documentText()).toContain("Source repos");
expect(documentText()).toContain("Add GitHub repo");
expect(documentText()).not.toContain("Repo URL");
expect(documentText()).not.toContain("Local folder");
expect(localPathInput()).toBeNull();
expect(document.body.querySelector('input[placeholder="Project name"]')).not.toBeNull();
const chooseButtons = Array.from(document.body.querySelectorAll("button")).filter(
(button) => button.textContent?.trim() === "Choose",
);
expect(chooseButtons).toHaveLength(0);
// The repo field is unrelated to the host filesystem, so it stays.
expect(documentText()).toContain("Repo URL");
});
});

View File

@ -1,457 +1,67 @@
import { useMemo, useRef, useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useRef, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { ProjectRepository } from "@paperclipai/shared";
import { Folder, X } from "lucide-react";
import { useDialog } from "../context/DialogContext";
import { useCompany } from "../context/CompanyContext";
import { accessApi } from "../api/access";
import { projectsApi } from "../api/projects";
import { agentsApi } from "../api/agents";
import { goalsApi } from "../api/goals";
import { assetsApi } from "../api/assets";
import { buildMarkdownMentionOptions } from "../lib/company-members";
import { queryKeys } from "../lib/queryKeys";
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Maximize2,
Minimize2,
Target,
Calendar,
Plus,
X,
HelpCircle,
} from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "../lib/utils";
import { MarkdownEditor, type MarkdownEditorRef, type MentionOption } from "./MarkdownEditor";
import { StatusBadge } from "./StatusBadge";
import { ChoosePathButton } from "./PathInstructionsModal";
import { useManagedSandboxOnly } from "../hooks/useManagedSandboxOnly";
const projectStatuses = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
export function NewProjectDialog() {
const { newProjectOpen, closeNewProject } = useDialog();
const { selectedCompanyId, selectedCompany } = useCompany();
const queryClient = useQueryClient();
const { hideHostPaths } = useManagedSandboxOnly();
const { selectedCompanyId } = useCompany();
return selectedCompanyId && newProjectOpen
? <NewProjectForm key={selectedCompanyId} companyId={selectedCompanyId} onClose={closeNewProject} /> : null;
}
export function NewProjectForm({ companyId, onClose }: { companyId: string; onClose: () => void }) {
const client = useQueryClient();
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("planned");
const [goalIds, setGoalIds] = useState<string[]>([]);
const [targetDate, setTargetDate] = useState("");
const [expanded, setExpanded] = useState(false);
const [workspaceLocalPath, setWorkspaceLocalPath] = useState("");
const [workspaceRepoUrl, setWorkspaceRepoUrl] = useState("");
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
const [statusOpen, setStatusOpen] = useState(false);
const [goalOpen, setGoalOpen] = useState(false);
const descriptionEditorRef = useRef<MarkdownEditorRef>(null);
const { data: goals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: agents } = useQuery({
queryKey: queryKeys.agents.list(selectedCompanyId!),
queryFn: () => agentsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const { data: companyMembers } = useQuery({
queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId!),
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId && newProjectOpen,
});
const mentionOptions = useMemo<MentionOption[]>(() => {
return buildMarkdownMentionOptions({
agents,
members: companyMembers?.users,
});
}, [agents, companyMembers?.users]);
const createProject = useMutation({
mutationFn: (data: Record<string, unknown>) =>
projectsApi.create(selectedCompanyId!, data),
});
const uploadDescriptionImage = useMutation({
mutationFn: async (file: File) => {
if (!selectedCompanyId) throw new Error("No organization selected");
return assetsApi.uploadImage(selectedCompanyId, file, "projects/drafts");
const [repos, setRepos] = useState<ProjectRepository[]>([]);
const [connecting, setConnecting] = useState(false);
const input = useRef<HTMLInputElement>(null);
const create = useMutation({
mutationFn: () => projectsApi.create(companyId, { name: name.trim(), status: "planned", repositoryIds: repos.map((repo) => repo.id) }),
onSuccess: () => {
void client.invalidateQueries({ queryKey: queryKeys.projects.all(companyId) });
onClose();
},
});
function reset() {
setName("");
setDescription("");
setStatus("planned");
setGoalIds([]);
setTargetDate("");
setExpanded(false);
setWorkspaceLocalPath("");
setWorkspaceRepoUrl("");
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,
status,
// No color is sent — new projects persist color = null (neutral gray). See PAP-68.
...(goalIds.length > 0 ? { goalIds } : {}),
...(targetDate ? { targetDate } : {}),
});
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);
}
queryClient.invalidateQueries({ queryKey: queryKeys.projects.all(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.projects.detail(created.id) });
reset();
closeNewProject();
} catch {
// surface through createProject.isError
}
}
function handleKeyDown(e: React.KeyboardEvent) {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
}
const selectedGoals = (goals ?? []).filter((g) => goalIds.includes(g.id));
const availableGoals = (goals ?? []).filter((g) => !goalIds.includes(g.id));
return (
<Dialog
open={newProjectOpen}
onOpenChange={(open) => {
if (!open) {
reset();
closeNewProject();
}
}}
>
<DialogContent
showCloseButton={false}
className={cn("p-0 gap-0", expanded ? "sm:max-w-2xl" : "sm:max-w-lg")}
onKeyDown={handleKeyDown}
>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-border">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{selectedCompany && (
<span className="bg-muted px-1.5 py-0.5 rounded text-xs font-medium">
{selectedCompany.issuePrefix}
</span>
)}
<span className="text-muted-foreground/60">&rsaquo;</span>
<span>New project</span>
return <Dialog open onOpenChange={(open) => { if (!open && !create.isPending) onClose(); }}>
<DialogContent showCloseButton={false} aria-describedby={undefined}
className="flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 shadow-sm sm:max-w-lg"
onOpenAutoFocus={(event) => { event.preventDefault(); input.current?.focus(); }}>
{connecting ? <div className="min-h-0 overflow-y-auto p-5">
<DialogTitle className="sr-only">Connect GitHub</DialogTitle>
<ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onCancel={() => setConnecting(false)} onComplete={() => {
void client.invalidateQueries({ queryKey: repositoryOptionsKey(companyId) });
setConnecting(false);
}} />
</div> : <form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim() && !create.isPending) create.mutate(); }}>
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
<Button type="button" variant="ghost" size="icon-sm" disabled={create.isPending} aria-label="Close new project" onClick={onClose}><X className="size-4" /></Button>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => setExpanded(!expanded)}
>
{expanded ? <Minimize2 className="h-3.5 w-3.5" /> : <Maximize2 className="h-3.5 w-3.5" />}
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
onClick={() => { reset(); closeNewProject(); }}
>
<span className="text-lg leading-none">&times;</span>
</Button>
<div className="flex items-center gap-3 rounded-lg border border-input px-3 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<Folder className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input ref={input} aria-label="Project name" value={name} disabled={create.isPending} onChange={(event) => setName(event.target.value)} placeholder="Project name" required
className="h-10 w-full min-w-0 border-0 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm" />
</div>
</div>
{/* Name */}
<div className="px-4 pt-4 pb-2 shrink-0">
<input
className="w-full text-lg font-semibold bg-transparent outline-none placeholder:text-muted-foreground/50"
placeholder="Project name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Tab" && !e.shiftKey) {
e.preventDefault();
descriptionEditorRef.current?.focus();
}
}}
autoFocus
/>
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
<ProjectRepositoryInput companyId={companyId} selected={repos} onChange={setRepos} onConnect={() => setConnecting(true)} disabled={create.isPending} />
</div>
{/* Description */}
<div className="px-4 pb-2">
<MarkdownEditor
ref={descriptionEditorRef}
value={description}
onChange={setDescription}
placeholder="Add description..."
bordered={false}
mentions={mentionOptions}
contentClassName={cn("text-sm text-muted-foreground", expanded ? "min-h-(--sz-220px)" : "min-h-(--sz-120px)")}
imageUploadHandler={async (file) => {
const asset = await uploadDescriptionImage.mutateAsync(file);
return asset.contentPath;
}}
/>
{create.isError && <p role="alert" className="px-5 pt-3 text-sm text-destructive">{create.error.message}</p>}
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
<Button type="button" variant="ghost" disabled={create.isPending} onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={!name.trim() || create.isPending}>{create.isPending ? "Creating…" : "Create project"}</Button>
</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>
</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>
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
platform-managed environment, so the field and its folder picker
never render and the create request carries no cwd. The field also
stays hidden until the policy is known.
*/}
{!hideHostPaths && (
<div>
<div className="mb-1 flex items-center gap-1.5">
<label className="block text-xs text-muted-foreground">Local folder</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.
</TooltipContent>
</Tooltip>
</div>
<div className="flex items-center gap-2">
<input
className="w-full rounded border border-border bg-transparent px-2 py-1 text-xs font-mono outline-none"
value={workspaceLocalPath}
onChange={(e) => { setWorkspaceLocalPath(e.target.value); setWorkspaceError(null); }}
placeholder="/absolute/path/to/workspace"
/>
<ChoosePathButton />
</div>
</div>
)}
{workspaceError && (
<p className="text-xs text-destructive">{workspaceError}</p>
)}
</div>
{/* Property chips */}
<div className="flex items-center gap-1.5 px-4 py-2 border-t border-border flex-wrap">
{/* Status */}
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
<PopoverTrigger asChild>
<button className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors">
<StatusBadge status={status} />
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{projectStatuses.map((s) => (
<button
key={s.value}
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
s.value === status && "bg-accent"
)}
onClick={() => { setStatus(s.value); setStatusOpen(false); }}
>
{s.label}
</button>
))}
</PopoverContent>
</Popover>
{selectedGoals.map((goal) => (
<span
key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Target className="h-3 w-3 text-muted-foreground" />
<span className="max-w-(--sz-160px) truncate">{goal.title}</span>
<button
className="text-muted-foreground hover:text-foreground"
onClick={() => setGoalIds((prev) => prev.filter((id) => id !== goal.id))}
aria-label={`Remove goal ${goal.title}`}
type="button"
>
<X className="h-3 w-3" />
</button>
</span>
))}
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<button
className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs hover:bg-accent/50 transition-colors disabled:opacity-60"
disabled={selectedGoals.length > 0 && availableGoals.length === 0}
>
{selectedGoals.length > 0 ? <Plus className="h-3 w-3 text-muted-foreground" /> : <Target className="h-3 w-3 text-muted-foreground" />}
{selectedGoals.length > 0 ? "+ Goal" : "Goal"}
</button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{selectedGoals.length === 0 && (
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 text-muted-foreground"
onClick={() => setGoalOpen(false)}
>
No goal
</button>
)}
{availableGoals.map((g) => (
<button
key={g.id}
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50 truncate"
onClick={() => {
setGoalIds((prev) => [...prev, g.id]);
setGoalOpen(false);
}}
>
{g.title}
</button>
))}
{selectedGoals.length > 0 && availableGoals.length === 0 && (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals already selected.
</div>
)}
</PopoverContent>
</Popover>
{/* Target date */}
<div className="inline-flex items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs">
<Calendar className="h-3 w-3 text-muted-foreground" />
<input
type="date"
className="bg-transparent outline-none text-xs w-24"
value={targetDate}
onChange={(e) => setTargetDate(e.target.value)}
placeholder="Target date"
/>
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-2.5 border-t border-border">
{createProject.isError ? (
<p className="text-xs text-destructive">Failed to create project.</p>
) : (
<span />
)}
<Button
size="sm"
disabled={!name.trim() || createProject.isPending}
onClick={handleSubmit}
>
{createProject.isPending ? "Creating…" : "Create project"}
</Button>
</div>
</DialogContent>
</Dialog>
);
</form>}
</DialogContent>
</Dialog>;
}

View File

@ -125,10 +125,10 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
expect(buttonLabels()).not.toContain("Change local folder");
expect(container.querySelector('button[aria-label="Clear local folder"]')).toBeNull();
// The repo row is unrelated to the host filesystem, so it stays.
expect(container.textContent).toContain("Repo");
expect(container.textContent).toContain("Source repos");
});
it("keeps only the managed-folder label for a managed checkout when the policy is on", () => {
it("omits the host codebase section for a managed checkout when the policy is on", () => {
render(
makeProject(makeCodebase({
localFolder: null,
@ -138,7 +138,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
{ enableIsolatedWorkspaces: true, enableManagedSandboxOnly: true },
);
expect(container.textContent).toContain("Paperclip-managed folder.");
expect(container.textContent).not.toContain("Codebase");
expect(container.textContent).not.toContain(MANAGED_FOLDER);
expect(container.querySelector(".font-mono")?.textContent).not.toBe(MANAGED_FOLDER);
expect(buttonLabels()).not.toContain("Set local folder");
@ -151,7 +151,7 @@ describe("ProjectProperties — local folder under the managed-sandbox-only poli
expect(container.textContent).not.toContain("Local folder");
expect(container.textContent).not.toContain(LOCAL_FOLDER);
expect(container.textContent).toContain("Repo");
expect(container.textContent).toContain("Source repos");
});
it("never opens the absolute-path edit panel when the policy is on", () => {

View File

@ -1,24 +1,20 @@
import { useState } from "react";
import { useState, type ReactNode } from "react";
import { environmentDisplayLabel, filterManagedSandboxSelectableEnvironments } from "@/lib/managed-sandbox-environment";
import { Link } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Project, SharedWorkspaceConcurrency } from "@paperclipai/shared";
import { StatusBadge } from "./StatusBadge";
import { ProjectRepositories } from "./ProjectRepositories";
import { cn, formatDate } from "../lib/utils";
import { environmentsApi } from "../api/environments";
import { goalsApi } from "../api/goals";
import { instanceSettingsApi } from "../api/instanceSettings";
import { projectsApi } from "../api/projects";
import { secretsApi } from "../api/secrets";
import { useCompany } from "../context/CompanyContext";
import { queryKeys } from "../lib/queryKeys";
import { statusBadge, statusBadgeDefault } from "../lib/status-colors";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Plus, Trash2, X } from "lucide-react";
import { GithubIcon } from "@/components/icons/github-icon";
import { AlertCircle, Archive, ArchiveRestore, Check, ExternalLink, Loader2, Trash2 } from "lucide-react";
import { ChoosePathButton } from "./PathInstructionsModal";
import { ToggleSwitch } from "@/components/ui/toggle-switch";
import { DraftInput } from "./agent-config-primitives";
@ -26,16 +22,9 @@ import { InlineEditor } from "./InlineEditor";
import { EnvironmentVariablesEditor } from "./environment-variables-editor";
import { Badge } from "@/components/ui/badge";
const PROJECT_STATUSES = [
{ value: "backlog", label: "Backlog" },
{ value: "planned", label: "Planned" },
{ value: "in_progress", label: "In Progress" },
{ value: "completed", label: "Completed" },
{ value: "cancelled", label: "Cancelled" },
];
interface ProjectPropertiesProps {
project: Project;
repositories?: ReactNode;
onUpdate?: (data: Record<string, unknown>) => void;
onFieldUpdate?: (field: ProjectConfigFieldKey, data: Record<string, unknown>) => void;
getFieldSaveState?: (field: ProjectConfigFieldKey) => ProjectFieldSaveState;
@ -147,42 +136,6 @@ function PropertyRow({
);
}
function ProjectStatusPicker({ status, onChange }: { status: string; onChange: (status: string) => void }) {
const [open, setOpen] = useState(false);
const colorClass = statusBadge[status] ?? statusBadgeDefault;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
className={cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium whitespace-nowrap shrink-0 cursor-pointer hover:opacity-80 transition-opacity",
colorClass,
)}
>
{status.replace("_", " ")}
</button>
</PopoverTrigger>
<PopoverContent className="w-40 p-1" align="start">
{PROJECT_STATUSES.map((s) => (
<Button
key={s.value}
variant="ghost"
size="sm"
className={cn("w-full justify-start gap-2 text-xs", s.value === status && "bg-accent")}
onClick={() => {
onChange(s.value);
setOpen(false);
}}
>
{s.label}
</Button>
))}
</PopoverContent>
</Popover>
);
}
function ArchiveDangerZone({
project,
onArchive,
@ -248,14 +201,12 @@ function ArchiveDangerZone({
);
}
export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
export function ProjectProperties({ project, repositories, onUpdate, onFieldUpdate, getFieldSaveState, onArchive, archivePending }: ProjectPropertiesProps) {
const { selectedCompanyId } = useCompany();
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>) => {
@ -267,11 +218,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
};
const fieldState = (field: ProjectConfigFieldKey): ProjectFieldSaveState => getFieldSaveState?.(field) ?? "idle";
const { data: allGoals } = useQuery({
queryKey: queryKeys.goals.list(selectedCompanyId!),
queryFn: () => goalsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@ -307,24 +253,10 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
enabled: !!selectedCompanyId && environmentsEnabled,
});
const linkedGoalIds = project.goalIds.length > 0
? project.goalIds
: project.goalId
? [project.goalId]
: [];
const linkedGoals = project.goals.length > 0
? project.goals
: linkedGoalIds.map((id) => ({
id,
title: allGoals?.find((g) => g.id === id)?.title ?? id.slice(0, 8),
}));
const availableGoals = (allGoals ?? []).filter((g) => !linkedGoalIds.includes(g.id));
const workspaces = project.workspaces ?? [];
const codebase = project.codebase;
const primaryCodebaseWorkspace = project.primaryWorkspace ?? null;
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id);
const hasAdditionalLegacyWorkspaces = workspaces.some((workspace) => workspace.id !== primaryCodebaseWorkspace?.id && !workspace.metadata?.githubRepositoryId);
const executionWorkspacePolicy = project.executionWorkspacePolicy ?? null;
const executionWorkspacesEnabled = executionWorkspacePolicy?.enabled === true;
const isolatedWorkspacesEnabled = experimentalSettings?.enableIsolatedWorkspaces === true;
@ -373,7 +305,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();
@ -384,7 +315,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
mutationFn: (workspaceId: string) => projectsApi.removeWorkspace(project.id, workspaceId),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
@ -395,24 +325,12 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
projectsApi.updateWorkspace(project.id, workspaceId, data),
onSuccess: () => {
setWorkspaceCwd("");
setWorkspaceRepoUrl("");
setWorkspaceMode(null);
setWorkspaceError(null);
invalidateProject();
},
});
const removeGoal = (goalId: string) => {
if (!onUpdate && !onFieldUpdate) return;
commitField("goals", { goalIds: linkedGoalIds.filter((id) => id !== goalId) });
};
const addGoal = (goalId: string) => {
if ((!onUpdate && !onFieldUpdate) || linkedGoalIds.includes(goalId)) return;
commitField("goals", { goalIds: [...linkedGoalIds, goalId] });
setGoalOpen(false);
};
const updateExecutionWorkspacePolicy = (patch: Record<string, unknown>) => {
if (!onUpdate && !onFieldUpdate) return;
return {
@ -428,17 +346,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 {
@ -449,20 +356,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
}
};
const formatRepoUrl = (value: string) => {
try {
const parsed = new URL(value);
const segments = parsed.pathname.split("/").filter(Boolean);
if (segments.length < 2) return parsed.host;
const owner = segments[0];
const repo = segments[1]?.replace(/\.git$/i, "");
if (!owner || !repo) return parsed.host;
return `${parsed.host}/${owner}/${repo}`;
} catch {
return value;
}
};
const deriveSourceType = (cwd: string | null, repoUrl: string | null) => {
if (repoUrl) return "git_repo";
if (cwd) return "local_path";
@ -509,21 +402,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
@ -534,24 +412,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">
@ -589,83 +449,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</p>
)}
</PropertyRow>
<PropertyRow label={<FieldLabel label="Status" state={fieldState("status")} />}>
{onUpdate || onFieldUpdate ? (
<ProjectStatusPicker
status={project.status}
onChange={(status) => commitField("status", { status })}
/>
) : (
<StatusBadge status={project.status} />
)}
</PropertyRow>
{project.leadAgentId && (
<PropertyRow label="Lead">
<span className="text-sm font-mono">{project.leadAgentId.slice(0, 8)}</span>
</PropertyRow>
)}
<PropertyRow
label={<FieldLabel label="Goals" state={fieldState("goals")} />}
alignStart
valueClassName="space-y-2"
>
{linkedGoals.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{linkedGoals.map((goal) => (
<span
key={goal.id}
className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs"
>
<Link to={`/goals/${goal.id}`} className="hover:underline break-words min-w-0">
{goal.title}
</Link>
{(onUpdate || onFieldUpdate) && (
<button
className="text-muted-foreground hover:text-foreground"
type="button"
onClick={() => removeGoal(goal.id)}
aria-label={`Remove goal ${goal.title}`}
>
<X className="h-3 w-3" />
</button>
)}
</span>
))}
</div>
)}
{(onUpdate || onFieldUpdate) && (
<Popover open={goalOpen} onOpenChange={setGoalOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
size="xs"
className={cn("h-6 w-fit px-2", linkedGoals.length > 0 && "ml-1")}
disabled={availableGoals.length === 0}
>
<Plus className="h-3 w-3 mr-1" />
Goal
</Button>
</PopoverTrigger>
<PopoverContent className="w-56 p-1" align="start">
{availableGoals.length === 0 ? (
<div className="px-2 py-1.5 text-xs text-muted-foreground">
All goals linked.
</div>
) : (
availableGoals.map((goal) => (
<button
key={goal.id}
className="flex items-center w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => addGoal(goal.id)}
>
{goal.title}
</button>
))
)}
</PopoverContent>
</Popover>
)}
</PropertyRow>
{repositories ?? <ProjectRepositories key={project.id} project={project} />}
<PropertyRow
label={<FieldLabel label="Env" state={fieldState("env")} />}
alignStart
@ -673,6 +457,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
>
<div className="space-y-2">
<EnvironmentVariablesEditor
footerHint={null}
value={project.env ?? {}}
secrets={availableSecrets}
userSecretDefinitions={userSecretDefinitions}
@ -682,14 +467,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
}}
onChange={(env) => commitField("env", { env: env ?? null })}
/>
<p className="text-(length:--text-micro) text-muted-foreground">
Applied to all runs for tasks in this project. Project values override agent env on key conflicts.
</p>
</div>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
<PropertyRow label={<FieldLabel label="Updated" state="idle" />}>
<span className="text-sm">{formatDate(project.updatedAt)}</span>
</PropertyRow>
@ -703,7 +483,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
<Separator className="my-4" />
<div className="space-y-1 py-4">
<div className="space-y-2">
{(!hideHostPaths || (primaryCodebaseWorkspace?.runtimeServices?.length ?? 0) > 0) && <div className="space-y-2">
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Codebase</span>
<Tooltip>
@ -724,69 +504,6 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</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>
{codebase.repoUrl ? (
<div className="flex items-center justify-between gap-2">
{isSafeExternalUrl(codebase.repoUrl) ? (
<a
href={codebase.repoUrl}
target="_blank"
rel="noreferrer"
className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground hover:underline"
>
<GithubIcon className="h-3 w-3 shrink-0" />
<span className="break-all min-w-0">{formatRepoUrl(codebase.repoUrl)}</span>
<ExternalLink className="h-3 w-3 shrink-0" />
</a>
) : (
<div className="inline-flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
<GithubIcon className="h-3 w-3 shrink-0" />
<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>
</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>
{/*
The local folder is an absolute path on the execution host. Under
the managed-sandbox-only policy every agent runs in the
@ -939,39 +656,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>
)}
@ -984,7 +668,7 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
{updateWorkspace.isError && (
<p className="text-xs text-destructive">Failed to update workspace.</p>
)}
</div>
</div>}
{isolatedWorkspacesEnabled ? (
<>
@ -1324,6 +1008,9 @@ export function ProjectProperties({ project, onUpdate, onFieldUpdate, getFieldSa
</div>
</>
)}
<PropertyRow label={<FieldLabel label="Created" state="idle" />}>
<span className="text-sm">{formatDate(project.createdAt)}</span>
</PropertyRow>
</div>
);
}

View File

@ -0,0 +1,49 @@
import { LegacyProjectRepository } from "./LegacyProjectRepository";
import { useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { Project, ProjectRepository } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { queryKeys } from "@/lib/queryKeys";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { ProjectRepositoryInput, repositoryOptionsKey } from "./ProjectRepositoryInput";
import { Dialog, DialogContent, DialogTitle } from "./ui/dialog";
import { Button } from "./ui/button";
export function ProjectRepositories({ project }: { project: Project }) {
const client = useQueryClient();
const saved: ProjectRepository[] = project.workspaces.flatMap((workspace) => {
const id = workspace.metadata?.githubRepositoryId;
return typeof id === "string" && workspace.repoUrl ? [{ id, fullName: workspace.name, url: workspace.repoUrl, connections: [] }] : [];
});
const [draft, setDraft] = useState<ProjectRepository[] | null>(null);
const [connecting, setConnecting] = useState(false);
const save = useMutation({
mutationFn: () => projectsApi.setRepositories(project.id, (draft ?? saved).map((repo) => repo.id)),
onSuccess: (updated) => {
for (const ref of new Set([project.id, project.urlKey])) {
client.setQueriesData({ queryKey: queryKeys.projects.detail(ref) }, updated);
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(ref) });
}
void client.invalidateQueries({ queryKey: queryKeys.projects.all(project.companyId) });
void client.invalidateQueries({ queryKey: queryKeys.projects.detail(project.id) });
setDraft(null);
},
});
return <section aria-label="Repositories" className="flex min-w-0 flex-col gap-4 py-4">
<ProjectRepositoryInput companyId={project.companyId} selected={draft ?? saved} onChange={(repos) => { setDraft(repos); save.reset(); }} onConnect={() => setConnecting(true)} disabled={save.isPending} />
{project.workspaces.filter((workspace) => workspace.repoUrl && !workspace.metadata?.githubRepositoryId).map((workspace) => <LegacyProjectRepository key={workspace.id} workspace={workspace} projectRef={project.urlKey} />)}
<div className="flex flex-wrap items-center justify-end gap-2 border-t border-border pt-4">
{save.isError && <p role="alert" className="mr-auto text-sm text-destructive">{save.error.message}</p>}
{save.isSuccess && <span role="status" className="mr-auto text-sm text-muted-foreground">Changes saved</span>}
{draft && <Button type="button" variant="ghost" disabled={save.isPending} onClick={() => { setDraft(null); save.reset(); }}>Discard changes</Button>}
<Button type="button" disabled={!draft || save.isPending} onClick={() => save.mutate()}>{save.isPending ? "Saving…" : "Save changes"}</Button>
</div>
<Dialog open={connecting} onOpenChange={setConnecting}><DialogContent showCloseButton={false} aria-describedby={undefined} className="max-h-(--sz-calc-18) overflow-y-auto sm:max-w-2xl">
<DialogTitle className="sr-only">Connect GitHub</DialogTitle>
<ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onCancel={() => setConnecting(false)} onComplete={() => {
void client.invalidateQueries({ queryKey: repositoryOptionsKey(project.companyId) });
setConnecting(false);
}} />
</DialogContent></Dialog>
</section>;
}

View File

@ -0,0 +1,29 @@
import { useQuery } from "@tanstack/react-query";
import type { ProjectRepository } from "@paperclipai/shared";
import { projectsApi } from "@/api/projects";
import { RepositoryEditor } from "./RepositoryEditor";
import { Button } from "./ui/button";
export const repositoryOptionsKey = (companyId: string) => ["project-repositories", companyId] as const;
export function ProjectRepositoryInput({ companyId, selected, onChange, onConnect, disabled }: {
companyId: string;
selected: ProjectRepository[];
onChange: (repos: ProjectRepository[]) => void;
onConnect: () => void;
disabled?: boolean;
}) {
const query = useQuery({ queryKey: repositoryOptionsKey(companyId), queryFn: () => projectsApi.repositoryOptions(companyId), staleTime: 30_000 });
const state = query.isPending ? "loading" : query.isError ? "error"
: !query.data.connectionCount ? "disconnected"
: query.data.failedConnectionCount && !query.data.repositories.length ? "error"
: !query.data.repositories.length ? "empty" : "ready";
return <div className="flex min-w-0 flex-col gap-3">
<RepositoryEditor selected={selected.map((repo) => query.data?.repositories.find((available) => available.id === repo.id) ?? repo)} onChange={onChange} available={query.data?.repositories} state={state}
onRetry={() => void query.refetch()} onConnect={onConnect} disabled={disabled} />
{!!query.data?.failedConnectionCount && query.data.repositories.length > 0 && <div role="alert" className="flex flex-wrap items-center gap-2 text-xs text-destructive">
Some GitHub connections could not load. Reconnect them in Apps or try again.
<Button type="button" variant="ghost" size="sm" disabled={query.isFetching} onClick={() => void query.refetch()}>Try again</Button>
</div>}
</div>;
}

View File

@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from "react";
import type { ProjectRepository } from "@paperclipai/shared";
import { GitBranch, LockKeyhole, Plus, X } from "lucide-react";
import { GithubIcon } from "./icons/github-icon";
import { SearchableSelect } from "./SearchableSelect";
import { Button } from "./ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
function RepoRow({ repo, onRemove }: { repo: ProjectRepository; onRemove: () => void }) {
return (
<div className="flex min-w-0 items-center gap-3 rounded-md border border-border px-3 py-2">
<GithubIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-1 flex-col gap-1">
<span className="truncate text-sm font-medium" title={repo.fullName}>{repo.fullName}</span>
{repo.connections.length > 0 && <span className="truncate text-xs text-muted-foreground" title={repo.connections.join(" · ")}>
{repo.connections.join(" · ")}
</span>}
</div>
{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" aria-label="Private repository" />}
<Button type="button" variant="ghost" size="icon-sm" aria-label={`Remove ${repo.fullName}`} onClick={onRemove}><X className="size-4" /></Button>
</div>
);
}
/** Shared by both review surfaces, ready to extract after design approval. */
export function RepositoryEditor({ selected, onChange, state = "ready", available = [], onRetry, onConnect, disabled = false }: {
selected: ProjectRepository[];
onChange: (repos: ProjectRepository[]) => void;
state?: "ready" | "loading" | "disconnected" | "empty" | "error";
available?: ProjectRepository[];
onRetry: () => void;
disabled?: boolean;
onConnect: () => void;
}) {
const [showPicker, setShowPicker] = useState(false);
const picker = useRef<HTMLDivElement>(null);
useEffect(() => {
if (showPicker) picker.current?.querySelector<HTMLButtonElement>('[role="combobox"]')?.focus();
}, [showPicker]);
const options = available.filter((repo) => !selected.some((item) => item.id === repo.id)).map((repo) => ({
key: repo.id, value: repo.id, label: repo.fullName,
searchText: repo.connections.join(" "), repo,
}));
const addClassName = selected.length ? "self-start" : "h-24 w-full flex-col gap-2";
const addLabel = selected.length ? "Add another repo" : "Add GitHub repo";
return (
<fieldset disabled={disabled} className="flex min-w-0 flex-col gap-3">
<div className="flex items-baseline gap-2"><span className="text-sm font-medium">Source repos</span><span className="text-xs text-muted-foreground">optional</span></div>
{selected.map((repo) => <RepoRow key={repo.id} repo={repo} onRemove={() => onChange(selected.filter((item) => item.id !== repo.id))} />)}
{state === "disconnected" ? (
<Popover>
<PopoverTrigger asChild><Button type="button" variant="outline" className={addClassName}><GithubIcon className="size-4" />{addLabel}</Button></PopoverTrigger>
<PopoverContent align="start" className="w-72">
<div className="flex flex-col gap-3">
<GithubIcon className="size-5" />
<div className="flex flex-col gap-1"><p className="text-sm font-medium">Connect GitHub to pick a repo</p><p className="text-xs text-muted-foreground">Choose from repos you can access through your GitHub connections.</p></div>
<Button type="button" onClick={onConnect}><GithubIcon className="size-4" />Connect GitHub</Button>
</div>
</PopoverContent>
</Popover>
) : showPicker ? (
<div ref={picker} className="flex flex-col gap-2">
<SearchableSelect<string, (typeof options)[number]>
value="" groups={[{ id: "available", label: "Available GitHub repos", options }]}
placeholder={addLabel} searchPlaceholder="Search GitHub repos…"
contentClassName="max-h-(--radix-popover-content-available-height) overflow-hidden [&_[data-slot=command]]:max-h-(--radix-popover-content-available-height) [&_[data-slot=command-list]]:min-h-0 [&_[data-slot=command-list]]:flex-1 [&_[data-slot=command-input-wrapper]]:shrink-0"
loading={state === "loading"} loadingMessage="Loading GitHub repos…"
emptyMessage={state === "error" ? "Couldnt load GitHub repos. Try again." : state === "empty" ? "No repos available. Connect an account with repo access." : "No matching repos. Try another search or connection."}
renderValue={() => <span className="flex items-center gap-2 text-foreground"><GithubIcon className="size-4" />{addLabel}</span>}
renderOption={({ repo }) => <><GitBranch className="size-4 shrink-0 text-muted-foreground" /><span className="flex min-w-0 flex-1 flex-col gap-1"><span className="truncate">{repo.fullName}</span><span className="truncate text-xs text-muted-foreground">{repo.connections.join(" · ")}</span></span>{repo.private && <LockKeyhole className="size-3 shrink-0 text-muted-foreground" />}</>}
onValueChange={(_, option) => { onChange([...selected, option.repo]); setShowPicker(false); }}
createItem={state === "error"
? { render: () => <>Try again</>, onSelect: () => { onRetry(); } }
: { render: () => <><Plus className="size-4" />Connect another GitHub account</>, onSelect: onConnect }}
/>
<div className="flex items-center justify-between gap-2">
<p className="text-xs text-muted-foreground">{"All GitHub connections you can use."}</p>
<Button type="button" variant="ghost" size="sm" onClick={() => setShowPicker(false)}>Cancel</Button>
</div>
</div>
) : <Button type="button" variant="outline" className={addClassName} onClick={() => setShowPicker(true)}><GithubIcon className="size-4" />{addLabel}</Button>}
</fieldset>
);
}

View File

@ -475,6 +475,7 @@ export interface ConnectionSetupFlowProps {
serviceSlug?: string;
requestedAgentId?: string;
interactionId?: string;
forceNewConnection?: boolean;
existingConnections?: ToolConnection[];
onUseExisting?: (connectionId: string) => Promise<void>;
onComplete?: (result: ConnectionSetupCompletion) => void;
@ -496,6 +497,7 @@ export function ConnectionSetupFlow({
serviceSlug,
requestedAgentId,
interactionId,
forceNewConnection = false,
existingConnections = [],
onUseExisting,
onComplete,
@ -519,7 +521,7 @@ export function ConnectionSetupFlow({
|| null;
const appKey = routeParams.appKey ?? searchParams.get("appKey") ?? undefined;
const sourceSlug = searchParams.get("source")?.trim() || null;
const createNewConnection = searchParams.get("new") === "1";
const createNewConnection = forceNewConnection || searchParams.get("new") === "1";
const routeStage = searchParams.get("stage")?.trim() || null;
const resumeConnectionId = searchParams.get("resume")?.trim() || null;
const oauthCallbackOutcome = searchParams.get("oauth");
@ -630,6 +632,7 @@ export function ConnectionSetupFlow({
const hydratedResumeConnectionIdRef = useRef<string | null>(null);
const [hydratedResumeConnectionId, setHydratedResumeConnectionId] = useState<string | null>(null);
const oauthPopupRef = useRef<Window | null>(null);
const [dialogOAuthConnectionId, setDialogOAuthConnectionId] = useState<string | null>(null);
const oauthHandoffAbortRef = useRef<AbortController | null>(null);
const [showConnectionChoice, setShowConnectionChoice] = useState(
existingConnections.length > 0 && Boolean(onUseExisting),
@ -715,6 +718,51 @@ export function ConnectionSetupFlow({
return () => window.removeEventListener("message", receiveOAuthOutcome);
}, [connectionIntentId, host, onComplete, onOAuthDeclined]);
// Standalone dialog hosts have no task interaction to receive a callback.
// Wait for this popup to return to our origin, then verify durable state via
// the API. Provider-window contents never determine the saved connection.
useEffect(() => {
if (host !== "dialog" || connectionIntentId || !dialogOAuthConnectionId) return;
let cancelled = false;
let checking = false;
const timer = window.setInterval(async () => {
if (checking || cancelled) return;
const popup = oauthPopupRef.current;
if (!popup || popup.closed) {
setDialogOAuthConnectionId(null);
setOAuthPhase("error");
setOAuthError("The sign-in window closed. Try again to finish connecting GitHub.");
return;
}
let returned: URL;
try { returned = new URL(popup.location.href); } catch { return; }
if (returned.origin !== window.location.origin || !returned.pathname.includes(dialogOAuthConnectionId)) return;
if (returned.searchParams.has("oauth")) {
setDialogOAuthConnectionId(null);
setOAuthPhase("error");
setOAuthError("Authorization did not complete. Finish setup in the sign-in window or try again.");
return;
}
if (returned.searchParams.get("success") !== "1") return;
checking = true;
try {
const connection = await toolsApi.getConnection(dialogOAuthConnectionId);
if (!cancelled && connection.status === "active") {
setDialogOAuthConnectionId(null);
popup.close();
onComplete?.({ connectionId: connection.id });
}
} catch {
if (!cancelled) {
setDialogOAuthConnectionId(null);
setOAuthPhase("error");
setOAuthError("Could not confirm the connection. Try again.");
}
} finally { checking = false; }
}, 1000);
return () => { cancelled = true; window.clearInterval(timer); };
}, [connectionIntentId, dialogOAuthConnectionId, host, onComplete]);
const resetGenericAuthState = () => {
setLinkAuthMode("auto");
setLinkHeaders([newCustomHeaderRow()]);
@ -893,7 +941,7 @@ export function ConnectionSetupFlow({
refetchOnMount: "always",
});
const existingOAuthConnection = useMemo(
() => reusableOAuthConnection(
() => forceNewConnection ? null : reusableOAuthConnection(
directOAuthSource,
applicationsQuery.data?.applications ?? [],
connectionsQuery.data?.connections ?? [],
@ -901,7 +949,7 @@ export function ConnectionSetupFlow({
? { applicationId: prefill.applicationId, draftOnly: true }
: {},
),
[applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId],
[applicationsQuery.data, connectionsQuery.data, createNewConnection, directOAuthSource, prefill.applicationId, forceNewConnection],
);
const reconnectConnection = useMemo(
() => reconnectConnectionId
@ -1015,8 +1063,9 @@ export function ConnectionSetupFlow({
const startOAuth = useCallback((connection: ToolConnection) => {
onPhaseChange?.("authorizing");
reserveOAuthPopup();
if (host === "dialog" && !connectionIntentId) setDialogOAuthConnectionId(connection.id);
mutateOAuthStart(connection);
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup]);
}, [mutateOAuthStart, onPhaseChange, reserveOAuthPopup, host, connectionIntentId]);
/**
* Commit the Access step's agent reach for a connection. Shared by the

View File

@ -1,3 +1,4 @@
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { useState } from "react";
import { ServicesList } from "./apps/app-detail/ServicesPanel";
import { ComposioProvenanceChip } from "./apps/ComposioProvenanceChip";
@ -2115,6 +2116,18 @@ export function DesignGuide() {
</SubSection>
</Section>
<Section title="Source Repositories">
<SubSection title="Empty and disconnected">
<RepositoryEditor selected={[]} onChange={() => {}} state="disconnected" onConnect={() => {}} onRetry={() => {}} />
</SubSection>
<SubSection title="Selected and searchable">
<RepositoryEditor selected={[{ id: "1", fullName: "paperclipai/paperclip", url: "https://github.com/paperclipai/paperclip", connections: ["Your GitHub"] }]}
available={[{ id: "2", fullName: "paperclipai/docs", url: "https://github.com/paperclipai/docs", connections: ["Company GitHub"] }]}
onChange={() => {}} onConnect={() => {}} onRetry={() => {}} />
</SubSection>
<p className="text-sm text-muted-foreground">Loading, errors, empty search, mobile, and short viewports are covered in the Project repos Storybook stories.</p>
</Section>
<Section title="Environment Variables Editor">
<p className="text-sm text-muted-foreground">
Reusable env-var editor (agents, projects, environments, routines). One shared grid, an

View File

@ -48,7 +48,7 @@ import {
/* ── Top-level tab types ── */
type ProjectBaseTab = "overview" | "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
type ProjectBaseTab = "list" | "plugin-operations" | "workspaces" | "configuration" | "budget";
type ProjectPluginTab = `plugin:${string}`;
type ProjectTab = ProjectBaseTab | ProjectPluginTab;
@ -61,7 +61,7 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
const projectsIdx = segments.indexOf("projects");
if (projectsIdx === -1 || segments[projectsIdx + 1] !== projectId) return null;
const tab = segments[projectsIdx + 2];
if (tab === "overview") return "overview";
if (tab === "overview") return "configuration";
if (tab === "configuration") return "configuration";
if (tab === "budget") return "budget";
if (tab === "issues") return "list";
@ -70,48 +70,6 @@ function resolveProjectTab(pathname: string, projectId: string): ProjectTab | nu
return null;
}
/* ── Overview tab content ── */
function OverviewContent({
project,
onUpdate,
imageUploadHandler,
}: {
project: { description: string | null; status: string; targetDate: string | null };
onUpdate: (data: Record<string, unknown>) => void;
imageUploadHandler?: (file: File) => Promise<string>;
}) {
return (
<div className="space-y-6">
<InlineEditor
value={project.description ?? ""}
onSave={(description) => onUpdate({ description })}
nullable
as="p"
className="text-sm text-muted-foreground"
placeholder="Add a description..."
multiline
imageUploadHandler={imageUploadHandler}
/>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Status</span>
<div className="mt-1">
<StatusBadge status={project.status} />
</div>
</div>
{project.targetDate && (
<div>
<span className="text-muted-foreground">Target Date</span>
<p>{project.targetDate}</p>
</div>
)}
</div>
</div>
);
}
/* ── Combined icon + color picker popover (PAP-72 / PAP-68 part 4) ── */
const DEFAULT_PROJECT_ICON = "folder";
@ -536,15 +494,11 @@ export function ProjectDetail() {
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
if (routeProjectRef === canonicalProjectRef && !location.pathname.endsWith("/overview")) return;
if (isProjectPluginTab(activeTab)) {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(activeTab)}`, { replace: true });
return;
}
if (activeTab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`, { replace: true });
return;
}
if (activeTab === "configuration") {
navigate(`/projects/${canonicalProjectRef}/configuration`, { replace: true });
return;
@ -570,7 +524,7 @@ export function ProjectDetail() {
return;
}
navigate(`/projects/${canonicalProjectRef}`, { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate]);
}, [project, routeProjectRef, canonicalProjectRef, activeTab, filter, navigate, location.pathname]);
useEffect(() => {
closePanel();
@ -695,7 +649,7 @@ export function ProjectDetail() {
try { cachedTab = localStorage.getItem(`paperclip:project-tab:${project.id}`); } catch {}
}
if (cachedTab === "overview") {
return <Navigate to={`/projects/${canonicalProjectRef}/overview`} replace />;
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
}
if (cachedTab === "configuration") {
return <Navigate to={`/projects/${canonicalProjectRef}/configuration`} replace />;
@ -740,9 +694,7 @@ export function ProjectDetail() {
navigate(`/projects/${canonicalProjectRef}?tab=${encodeURIComponent(tab)}`);
return;
}
if (tab === "overview") {
navigate(`/projects/${canonicalProjectRef}/overview`);
} else if (tab === "workspaces") {
if (tab === "workspaces") {
navigate(`/projects/${canonicalProjectRef}/workspaces`);
} else if (tab === "budget") {
navigate(`/projects/${canonicalProjectRef}/budget`);
@ -879,7 +831,7 @@ export function ProjectDetail() {
<PageTabBar
items={[
{ value: "list", label: "Tasks" },
{ value: "overview", label: "Overview" },
...(project.managedByPlugin ? [{ value: "plugin-operations", label: "Plugin operations" }] : []),
...(showWorkspacesTab ? [{ value: "workspaces", label: "Workspaces" }] : []),
{ value: "configuration", label: "Configuration" },
@ -895,16 +847,7 @@ export function ProjectDetail() {
/>
</Tabs>
{activeTab === "overview" && (
<OverviewContent
project={project}
onUpdate={(data) => updateProject.mutate(data)}
imageUploadHandler={async (file) => {
const asset = await uploadImage.mutateAsync(file);
return asset.contentPath;
}}
/>
)}
{activeTab === "list" && project?.id && resolvedCompanyId && (
<ProjectIssuesList projectId={project.id} companyId={resolvedCompanyId} />

View File

@ -13,6 +13,7 @@ import { AppsConnect } from "./AppsConnect";
const listGalleryMock = vi.hoisted(() => vi.fn());
const listApplicationsMock = vi.hoisted(() => vi.fn());
const listConnectionsMock = vi.hoisted(() => vi.fn());
const getConnectionMock = vi.hoisted(() => vi.fn());
const connectAppMock = vi.hoisted(() => vi.fn());
const startOAuthMock = vi.hoisted(() => vi.fn());
const finishAppMock = vi.hoisted(() => vi.fn());
@ -57,6 +58,7 @@ vi.mock("@/api/tools", () => ({
listGallery: (companyId: string) => listGalleryMock(companyId),
listApplications: (companyId: string) => listApplicationsMock(companyId),
listConnections: (companyId: string) => listConnectionsMock(companyId),
getConnection: (id: string) => getConnectionMock(id),
connectApp: (companyId: string, input: unknown) => connectAppMock(companyId, input),
startOAuth: (connectionId: string, input?: unknown) => startOAuthMock(connectionId, input),
finishApp: (companyId: string, connectionId: string, input: unknown) =>
@ -1432,6 +1434,35 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
await act(async () => dialogRoot.unmount());
});
it("returns a standalone GitHub popup to its host only after verifying the saved connection", async () => {
listGalleryMock.mockResolvedValue({ apps: [GITHUB_MANAGED] });
connectAppMock.mockResolvedValue({
connectionId: "conn-github", application: { id: "app-github", name: "GitHub" },
connection: { id: "conn-github", credentialPolicy: "per_user" },
actions: { readOnly: [], canMakeChanges: [] }, catalog: [], suggestedDefaults: {},
auth: { kind: "oauth" },
});
const popup = { closed: false, location: { href: "about:blank", assign: vi.fn() }, focus: vi.fn(), close: vi.fn() };
vi.spyOn(window, "open").mockReturnValue(popup as unknown as Window);
const onComplete = vi.fn();
getConnectionMock.mockResolvedValue({ id: "conn-github", status: "active" });
const dialogRoot = await render(undefined, false, <ConnectionSetupFlow host="dialog" serviceSlug="github" forceNewConnection onComplete={onComplete} />);
await passAccessStep();
await act(async () => buttonByText("Continue to GitHub")!.click());
await flushReact();
await flushReact();
expect(connectAppMock, container.textContent ?? "").toHaveBeenCalled();
expect(startOAuthMock, container.textContent ?? "").toHaveBeenCalledWith("conn-github", { asCurrentUser: true });
expect(onComplete).not.toHaveBeenCalled();
popup.location.href = `${window.location.origin}/CO/apps/conn-github/permissions?success=1`;
await act(async () => {
await vi.waitFor(() => expect(onComplete).toHaveBeenCalledWith({ connectionId: "conn-github" }), { timeout: 2500 });
});
expect(getConnectionMock).toHaveBeenCalledWith("conn-github");
expect(popup.close).toHaveBeenCalled();
await act(async () => dialogRoot.unmount());
});
it("keeps the task dialog recoverable when the browser blocks its reserved OAuth popup", async () => {
listGalleryMock.mockResolvedValue({ apps: [NOTION] });
connectAppMock.mockResolvedValue({

View File

@ -0,0 +1,86 @@
import { useMemo, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { Boxes, ChevronRight, ChevronsUpDown, CircleCheck, Folder, History, Inbox, LayoutDashboard, Menu, Package, Repeat, Search, SquarePen, Star, Unplug, Users } from "lucide-react";
import { ProjectProperties } from "@/components/ProjectProperties";
import { PageTabBar } from "@/components/PageTabBar";
import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { queryKeys } from "@/lib/queryKeys";
import { cn } from "@/lib/utils";
import { storybookProjects } from "../../fixtures/paperclipData";
import { RepositoryConfigurationSection, type PrototypeProps } from "./ProjectReposPrototype";
const navigation = [
{ label: "Search", icon: Search, path: "search" },
{ label: "Dashboard", icon: LayoutDashboard, path: "dashboard" },
{ label: "Inbox", icon: Inbox, path: "inbox" },
{ heading: "Work", label: "Tasks", icon: CircleCheck, path: "issues" },
{ label: "Projects", icon: Folder, path: "projects" },
{ label: "Routines", icon: Repeat, path: "routines" },
{ label: "Artifacts", icon: Package, path: "artifacts" },
{ heading: "Org", label: "Agents", icon: Users, path: "agents" },
{ label: "Skills", icon: Boxes, path: "skills" },
{ label: "Connectors", icon: Unplug, path: "apps" },
{ label: "Audit", icon: History, path: "activity" },
];
function ReferenceSidebar() {
return (
<aside className="flex h-full w-60 shrink-0 flex-col bg-sidebar text-sidebar-foreground" aria-label="Bull navigation">
<div className="flex h-14 shrink-0 items-center gap-2 px-6 text-sm font-semibold"><span className="flex size-5 items-center justify-center rounded-md border border-sidebar-border text-xs">B</span>Bull<ChevronsUpDown className="ml-auto size-3 text-muted-foreground" /></div>
<nav className="flex min-h-0 flex-1 flex-col gap-1 overflow-y-auto p-4">
<a className="flex items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-sidebar-accent" href="https://bull.staging.paperclip.app/BUL/issues" target="_blank" rel="noreferrer"><SquarePen className="size-4" />New Task</a>
{navigation.map(({ label, icon: Icon, path, heading }) => <div key={label}>
{heading && <p className="px-3 pb-2 pt-6 font-mono text-xs uppercase text-muted-foreground">{heading}</p>}
<a href={`https://bull.staging.paperclip.app/BUL/${path}`} target="_blank" rel="noreferrer" aria-current={path === "projects" ? "page" : undefined} className={cn("flex items-center gap-3 rounded-md px-3 py-2 text-sm hover:bg-sidebar-accent", path === "projects" && "bg-sidebar-accent")}><Icon className="size-4" />{label}</a>
</div>)}
</nav>
<div className="shrink-0 px-6 py-4 text-xs text-muted-foreground">Bull workspace</div>
</aside>
);
}
export function ProjectConfigurationPrototype(props: PrototypeProps) {
const client = useMemo(() => {
const c = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } } });
c.setQueryData(queryKeys.instance.experimentalSettings, { enableManagedSandboxOnly: true, enableIsolatedWorkspaces: false, enableEnvironments: false });
c.setQueryData(queryKeys.goals.list("company-storybook"), []);
c.setQueryData(queryKeys.secrets.list("company-storybook"), []);
c.setQueryData(queryKeys.secrets.userDefinitions("company-storybook"), []);
return c;
}, []);
const [project, setProject] = useState<Project>(() => ({
...storybookProjects[0]!, id: "project-onboarding-preview", name: "Onboarding", urlKey: "onboarding",
description: null, status: "in_progress", leadAgentId: null, goalId: null, goalIds: [], goals: [], env: null,
archivedAt: null, targetDate: null, workspaces: [], primaryWorkspace: null,
createdAt: new Date("2026-09-07T12:00:00Z"), updatedAt: new Date("2026-09-07T12:00:00Z"),
}));
const [activeTab, setActiveTab] = useState("configuration");
const [starred, setStarred] = useState(false);
const [mobileNav, setMobileNav] = useState(false);
return (
<QueryClientProvider client={client}>
<div className="flex h-dvh min-h-0 overflow-hidden bg-background">
<div className="hidden md:block"><ReferenceSidebar /></div>
{mobileNav && <div className="fixed inset-0 z-50 flex md:hidden"><ReferenceSidebar /><button aria-label="Close navigation" className="flex-1 bg-background/80" onClick={() => setMobileNav(false)} /></div>}
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-4 md:px-6">
<Button variant="ghost" size="icon-sm" className="md:hidden" aria-label="Open navigation" onClick={() => setMobileNav(true)}><Menu className="size-4" /></Button>
<span className="text-xs uppercase tracking-wide text-muted-foreground">Projects</span><ChevronRight className="size-3 text-muted-foreground" /><span className="truncate text-sm">{project.name}</span>
</div>
<main className="min-h-0 min-w-0 flex-1 overflow-y-auto overscroll-contain p-4 md:p-6" aria-label="Project configuration page">
<div className="flex flex-col gap-6">
<header className="flex items-center gap-3"><span className="flex size-7 items-center justify-center rounded-md bg-muted"><Folder className="size-4 text-muted-foreground" /></span><h1 className="min-w-0 flex-1 truncate text-xl font-bold">{project.name}</h1><Button variant="ghost" size="icon-sm" aria-label={`Star ${project.name}`} aria-pressed={starred} onClick={() => setStarred(!starred)}><Star className={cn("size-4", starred && "fill-current")} /></Button></header>
<Tabs value={activeTab} onValueChange={setActiveTab}><PageTabBar align="start" value={activeTab} onValueChange={setActiveTab} items={[{ value: "list", label: "Tasks" }, { value: "configuration", label: "Configuration" }, { value: "budget", label: "Budget" }]} /></Tabs>
<div className={activeTab === "configuration" ? "max-w-4xl" : "hidden"}>
<ProjectProperties project={project} onUpdate={(data) => setProject((previous) => ({ ...previous, ...data }))} onArchive={(archived) => setProject((previous) => ({ ...previous, archivedAt: archived ? new Date() : null }))} repositories={<RepositoryConfigurationSection {...props} />} />
</div>
{activeTab !== "configuration" && <div className="flex flex-col items-start gap-3 py-6"><p className="text-sm text-muted-foreground">This story previews the Configuration tab.</p><Button variant="outline" onClick={() => setActiveTab("configuration")}>Return to configuration</Button></div>}
</div>
</main>
</div>
</div>
</QueryClientProvider>
);
}

View File

@ -0,0 +1,155 @@
import { RepositoryEditor as ProductionRepositoryEditor } from "@/components/RepositoryEditor";
import { useEffect, useMemo, useRef, useState } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
import { Check, GitBranch, Link, LockKeyhole, Folder, Plus, X } from "lucide-react";
import { GithubIcon } from "@/components/icons/github-icon";
import { SearchableSelect } from "@/components/SearchableSelect";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ConnectionSetupFlow } from "@/features/connections/ConnectionSetupFlow";
import { queryKeys } from "@/lib/queryKeys";
import { availableRepositories, type Repository, type RepositoryState } from "./fixtures";
// Everything in this directory is a local, interactive design proposal.
// Production screens, persistence, authorization, and API contracts are unchanged.
function GitHubConnectionPreview({ onComplete, onCancel }: { onComplete: () => void; onCancel: () => void }) {
const client = useMemo(() => {
const c = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, refetchOnMount: false } } });
c.setQueryData(queryKeys.apps.gallery("company-storybook"), {
// Mirror an enrolled instance's gallery response; raw catalog defaults
// hide platform_shared OAuth and would otherwise show the PAT fallback.
apps: CONNECTABLE_APP_DEFINITIONS.filter((app) => app.slug === "github").map((app) => ({
...app,
ownershipAvailability: { platform_shared: true, platform_provisioned: false, customer: true, dcr: true },
})),
capabilities: { canSetCompanyInstall: true, canCreateOrganizationGrant: true, canConnectAsCurrentUser: true },
});
c.setQueryData(queryKeys.tools.applications("company-storybook"), { applications: [] });
c.setQueryData(queryKeys.tools.connections("company-storybook"), { connections: [] });
return c;
}, []);
return (
<QueryClientProvider client={client}>
<div onClickCapture={(event) => {
// Use the exact existing connector UI. Only the provider handoff is
// simulated: no popup, OAuth request, or connection mutation in stories.
const button = (event.target as HTMLElement).closest("button");
if (button?.textContent?.trim() === "Continue to GitHub") {
event.preventDefault();
event.stopPropagation();
onComplete();
}
}}>
<ConnectionSetupFlow host="dialog" serviceSlug="github" onComplete={onComplete} onCancel={onCancel} />
</div>
</QueryClientProvider>
);
}
function RepositoryEditor({ selected, onChange, initialState = "ready", personalOnly = false, onConnect }: {
selected: Repository[]; onChange: (repos: Repository[]) => void; initialState?: RepositoryState; personalOnly?: boolean; onConnect: () => void;
}) {
const [state, setState] = useState(initialState);
return <ProductionRepositoryEditor selected={selected} onChange={(repos) => onChange(repos.map((repo) => ({ ...repo, private: repo.private ?? false })))} state={state}
available={state === "ready" ? availableRepositories(personalOnly) : []}
onConnect={onConnect} onRetry={() => setState("ready")} />;
}
export interface PrototypeProps {
initialName?: string;
initialState?: RepositoryState;
initialRepoIds?: string[];
personalOnly?: boolean;
legacyUrl?: string;
startConnecting?: boolean;
}
function useRepoDraft(props: PrototypeProps) {
const [repos, setRepos] = useState(() => availableRepositories().filter((repo) => props.initialRepoIds?.includes(repo.id)));
const [connecting, setConnecting] = useState(props.startConnecting ?? false);
const [connected, setConnected] = useState(false);
const [revision, setRevision] = useState(0);
return { repos, setRepos, connecting, setConnecting, revision,
state: connected ? "ready" as const : props.initialState ?? "ready",
finishConnection: () => { setConnected(true); setConnecting(false); setRevision((n) => n + 1); },
};
}
export function NewProjectPrototype(props: PrototypeProps) {
const draft = useRepoDraft(props);
const [name, setName] = useState(props.initialName ?? "");
const [open, setOpen] = useState(true);
const [created, setCreated] = useState(false);
const input = useRef<HTMLInputElement>(null);
return (
<div className="flex min-h-screen items-center justify-center p-6">
<Button type="button" onClick={() => { setOpen(true); setCreated(false); }}>Open new project</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent showCloseButton={false} aria-describedby={undefined}
className="flex max-h-(--sz-calc-18) flex-col gap-0 overflow-hidden p-0 shadow-sm sm:max-w-lg"
onOpenAutoFocus={(event) => { event.preventDefault(); input.current?.focus(); input.current?.select(); }}>
{(draft.connecting || created) && <DialogTitle className="sr-only">{draft.connecting ? "Connect GitHub" : "Create project"}</DialogTitle>}
{draft.connecting ? <div className="min-h-0 overflow-y-auto p-5"><GitHubConnectionPreview onComplete={draft.finishConnection} onCancel={() => draft.setConnecting(false)} /></div> : created ? (
<div className="flex flex-col items-center gap-4 p-8">
<Check className="size-6" /><h2 className="text-xl font-bold">{name}</h2>
<p role="status" className="text-sm text-muted-foreground">Project preview created with {draft.repos.length} {draft.repos.length === 1 ? "repo" : "repos"}.</p>
<Button type="button" onClick={() => setCreated(false)}>Back to project draft</Button>
</div>
) : (
<form className="flex min-h-0 flex-col overflow-hidden" onSubmit={(event) => { event.preventDefault(); if (name.trim()) setCreated(true); }}>
<div className="flex shrink-0 flex-col gap-4 px-5 pb-4 pt-5">
<div className="flex items-center justify-between gap-3">
<DialogTitle className="text-lg font-semibold">Create project</DialogTitle>
<Button type="button" variant="ghost" size="icon-sm" aria-label="Close new project" onClick={() => setOpen(false)}><X className="size-4" /></Button>
</div>
<div className="flex items-center gap-3 rounded-lg border border-input px-3 focus-within:border-ring focus-within:ring-1 focus-within:ring-ring">
<Folder className="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<input ref={input} aria-label="Project name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Project name" required
className="h-10 w-full min-w-0 border-0 bg-transparent text-base outline-none placeholder:text-muted-foreground md:text-sm" />
</div>
</div>
<div role="region" aria-label="Source repositories" tabIndex={0} className="min-h-0 overflow-y-auto overscroll-contain px-5 pb-1 outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-ring">
<RepositoryEditor key={draft.revision} selected={draft.repos} onChange={draft.setRepos} initialState={draft.state} personalOnly={props.personalOnly} onConnect={() => draft.setConnecting(true)} />
</div>
<div className="flex shrink-0 justify-end gap-2 px-5 py-5">
<Button type="button" variant="ghost" onClick={() => setOpen(false)}>Cancel</Button>
<Button type="submit" disabled={!name.trim()}>Create project</Button>
</div>
</form>
)}
</DialogContent>
</Dialog>
</div>
);
}
export function RepositoryConfigurationSection(props: PrototypeProps) {
const draft = useRepoDraft(props);
const [legacyUrl, setLegacyUrl] = useState(props.legacyUrl ?? "");
const [editingLegacy, setEditingLegacy] = useState(false);
const [saved, setSaved] = useState({ repos: draft.repos, legacyUrl });
const [notice, setNotice] = useState("");
const dirty = JSON.stringify(draft.repos) !== JSON.stringify(saved.repos) || legacyUrl !== saved.legacyUrl;
return (
<div>
<section className="flex flex-col gap-4" aria-label="Repositories">
<RepositoryEditor key={draft.revision} selected={draft.repos} onChange={(repos) => { draft.setRepos(repos); setNotice(""); }} initialState={draft.state} personalOnly={props.personalOnly} onConnect={() => draft.setConnecting(true)} />
{props.legacyUrl && <div className="flex flex-col gap-2">
<span className="text-xs text-muted-foreground">Existing repo URL</span>
{editingLegacy ? <div className="flex flex-col gap-2"><Input aria-label="Existing repo URL" type="url" value={legacyUrl} onChange={(event) => { setLegacyUrl(event.target.value); setNotice(""); }} /><Button type="button" size="sm" variant="ghost" className="self-start" onClick={() => setEditingLegacy(false)}>Done</Button></div> : <div className="flex items-center gap-3 rounded-md border border-border px-3 py-2"><Link className="size-4 shrink-0 text-muted-foreground" /><span className="min-w-0 flex-1 break-all text-sm">{legacyUrl || "No URL set"}</span><Button type="button" variant="ghost" size="sm" onClick={() => setEditingLegacy(true)}>Edit</Button></div>}
<p className="text-xs text-muted-foreground">Your existing URL is preserved alongside selected GitHub repos.</p>
</div>}
<div className="flex items-center justify-end gap-2 border-t border-border pt-4">
{notice && <span role="status" className="mr-auto text-sm text-muted-foreground">{notice}</span>}
{dirty && <Button type="button" variant="ghost" onClick={() => { draft.setRepos(saved.repos); setLegacyUrl(saved.legacyUrl); setNotice(""); }}>Discard changes</Button>}
<Button type="button" disabled={!dirty || editingLegacy} onClick={() => { setSaved({ repos: draft.repos, legacyUrl }); setNotice("Changes saved in preview"); }}>Save changes</Button>
</div>
</section>
<Dialog open={draft.connecting} onOpenChange={draft.setConnecting}><DialogContent showCloseButton={false} aria-describedby={undefined} className="max-h-dvh overflow-y-auto sm:max-w-2xl"><DialogTitle className="sr-only">Connect GitHub</DialogTitle><GitHubConnectionPreview onComplete={draft.finishConnection} onCancel={() => draft.setConnecting(false)} /></DialogContent></Dialog>
</div>
);
}

View File

@ -0,0 +1,56 @@
# Project repository proposal
Review under **Proposals → Project repos** in Storybook. The repository editor is shared with production. Stories use local
fixtures for review; production screens use authenticated API requests.
- New project follows the supplied layout: “Create project” heading, close on
the right, no expand control, an outlined folder beside the left-aligned,
initially focused “Project name” placeholder, and “Source repos · optional.”
There is no breadcrumb, description, status, goal, due date, or text URL field.
- The modal bounds its height to the dynamic viewport. Its title/name and
Cancel/Create actions stay visible while the source repo region scrolls.
The searchable dropdown has a separate scroll area bounded by Radix's
available viewport height, including when it opens above its trigger.
- Repo selection uses the existing SearchableSelect, searching accessible
personal/company connections, provider-ID deduplication, and repeatable
add/remove. Fixtures contain 63 unique accessible repos, one duplicate repo,
and an inaccessible personal connection. Selected repos leave the picker.
- GitHub setup uses the actual ConnectionSetupFlow. The story intercepts
“Continue to GitHub” and simulates a successful return without starting OAuth.
The project name and selected repos survive connect/cancel.
- Configuration now previews the whole Onboarding configuration tab, based on
https://bull.staging.paperclip.app/BUL/projects/onboarding/configuration.
It includes navigation, breadcrumbs, project title/star, tabs, the actual
ProjectProperties general fields/environment editor/danger zone, and the
proposed repo editor above environment variables. Status and Goals are
omitted, Created is the last row after the danger zone, and the Overview
tab is removed. Updated remains below environment variables. The sidebar is a reference
shell; its links open staging in a separate tab. Non-configuration tabs are
explicitly outside this proposal. Field edits and saves stay in memory.
- The full configuration story composes ProjectProperties directly through its
repositories slot. No DOM manipulation or duplicated configuration page is used.
- Existing text URLs remain editable beside selected GitHub repos in the
configuration stories. There is no new manual URL entry point in creation.
## Review coverage
Both groups include forty-selected-repo, mobile, and short-viewport stories.
Creation also includes mobile/short searchable picker stories. The short mobile
viewport is 390 × 420, useful for reduced screen space such as a visible keyboard;
this does not emulate a native keyboard. Loading, failure/retry, no accessible
repos, no matches, personal-only, multiple-connection, and legacy states remain.
## Production contract
Projects persist selected GitHub identities in project workspaces using
`metadata.githubRepositoryId`. Each selection has its own workspace; the first
is the default execution workspace. Legacy `repoUrl` and local workspaces remain
supported. Runtime credentials still come from the existing run identity resolver;
a selection does not grant agents additional GitHub access.
The company repository endpoint includes only the current user's personal grants
and organization grants whose audience includes that user. It refreshes GitHub
installation metadata, supports PAT pagination, deduplicates provider IDs, and
reports partial failures. Creation validates all selections before saving the
project and repositories in one transaction. Configuration retains inaccessible
existing selections until explicitly removed and leaves legacy workspaces intact.

View File

@ -0,0 +1,55 @@
/** Review fixtures only. These are not an API contract or an authorization implementation. */
export interface Repository {
id: string;
fullName: string;
url: string;
private: boolean;
connections: string[];
}
interface Connection {
id: string;
label: string;
usable: boolean;
repos: Omit<Repository, "connections">[];
}
const repo = (id: string, fullName: string, isPrivate = true) => ({
id, fullName, url: `https://github.com/${fullName}`, private: isPrivate,
});
export const connections: Connection[] = [
{
id: "personal", label: "Your GitHub · dotta", usable: true,
repos: [repo("101", "dotta/papercool"), repo("102", "dotta/experiments"), repo("201", "papercool/web")],
},
{
id: "company", label: "Company GitHub · connected by Sam", usable: true,
repos: [repo("201", "papercool/web"), repo("202", "papercool/api"), repo("203", "papercool/docs", false),
...["design-system", "mobile", "infrastructure", "integrations", "cli", "analytics", "templates", "status", "website", "sdk"].map((name, i) => repo(String(300 + i), `papercool/${name}`)),
...Array.from({ length: 48 }, (_, i) => repo(String(400 + i), `papercool/service-${String(i + 1).padStart(2, "0")}`))],
},
{ id: "other-personal", label: "Sams private GitHub", usable: false, repos: [repo("900", "sam/private-project")] },
];
// Provider IDs keep one repository stable across connections and name changes.
export function availableRepositories(personalOnly = false): Repository[] {
const byId = new Map<string, Repository>();
for (const connection of connections.filter((c) => c.usable && (!personalOnly || c.id === "personal"))) {
for (const item of connection.repos) {
const existing = byId.get(item.id);
if (existing) existing.connections.push(connection.label);
else byId.set(item.id, { ...item, connections: [connection.label] });
}
}
return [...byId.values()].sort((a, b) => a.fullName.localeCompare(b.fullName));
}
export type RepositoryState = "ready" | "disconnected" | "loading" | "error" | "empty";
export const crowdedRepoIds = availableRepositories().slice(0, 40).map((repo) => repo.id);
export const reviewViewports = {
short: { name: "Short desktop", styles: { width: "1024px", height: "480px" } },
mobileShort: { name: "Short mobile / keyboard-sized", styles: { width: "390px", height: "420px" } },
};

View File

@ -0,0 +1,49 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { expect, userEvent, within } from "storybook/test";
import { NewProjectPrototype } from "../prototypes/project-repos/ProjectReposPrototype";
import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures";
const meta = {
title: "Proposals/Project repos/New project",
component: NewProjectPrototype,
tags: ["!autodocs"],
parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Interactive design proposal only. The project name starts empty and focused, with an outlined folder icon. The source repo region scrolls while the title, name, and actions remain visible. GitHub setup uses the existing ConnectionSetupFlow; Continue to GitHub simulates a successful provider return. Creation and repository changes stay in memory." } } },
} satisfies Meta<typeof NewProjectPrototype>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = { name: "01 · Name and optional repo" };
export const NoGitHubConnection: Story = { name: "02 · GitHub not connected", args: { initialState: "disconnected" } };
export const ExistingGitHubSetup: Story = { name: "03 · Existing GitHub connection UI", args: { initialState: "disconnected", startConnecting: true } };
export const AllAvailableRepos: Story = {
name: "04 · Search personal + company repos (deduplicated)",
play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" }));
await expect(screen.getAllByRole("option").filter((option) => within(option).queryByText("papercool/web", { exact: true }))).toHaveLength(1);
await expect(screen.queryByText("sam/private-project")).not.toBeInTheDocument();
},
};
export const PersonalConnectionOnly: Story = { name: "05 · Personal connection only", args: { personalOnly: true } };
export const OneRepo: Story = { name: "06 · One selected repo", args: { initialRepoIds: ["201"] } };
export const MultipleRepos: Story = { name: "07 · Multiple selected repos", args: { initialRepoIds: ["201", "202", "203"] } };
export const LoadingRepos: Story = { name: "08 · Loading repos", args: { initialState: "loading" }, play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" }));
} };
export const NoAccessibleRepos: Story = { name: "09 · Connected, no accessible repos", args: { initialState: "empty" }, play: LoadingRepos.play };
export const RepoLoadFailed: Story = { name: "10 · Load failed, retry available", args: { initialState: "error" }, play: LoadingRepos.play };
export const SearchNoResults: Story = { name: "11 · Search with no matches", play: async ({ canvasElement }) => {
const screen = within(canvasElement.ownerDocument.body);
await userEvent.click(await screen.findByRole("button", { name: "Add GitHub repo" }));
await userEvent.type(screen.getByPlaceholderText("Search GitHub repos…"), "no-such-repository");
} };
export const ManySelectedRepos: Story = { name: "12 · Forty selected repos", args: { initialName: "Onboarding", initialRepoIds: crowdedRepoIds } };
export const ShortViewport: Story = { ...ManySelectedRepos, name: "13 · Forty repos · short desktop", globals: { viewport: { value: "short", isRotated: false } } };
export const Mobile: Story = { name: "14 · Mobile · empty project", globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileManyRepos: Story = { ...ManySelectedRepos, name: "15 · Mobile · forty repos", globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileShortViewport: Story = { ...ManySelectedRepos, name: "16 · Mobile · short viewport", globals: { viewport: { value: "mobileShort", isRotated: false } } };
export const ShortRepoPicker: Story = { ...AllAvailableRepos, name: "17 · Scroll repo picker · short desktop", globals: { viewport: { value: "short", isRotated: false } } };
export const MobileRepoPicker: Story = { ...AllAvailableRepos, name: "18 · Scroll repo picker · short mobile", globals: { viewport: { value: "mobileShort", isRotated: false } } };

View File

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { ProjectConfigurationPrototype } from "../prototypes/project-repos/ProjectConfigurationPage";
import { crowdedRepoIds, reviewViewports } from "../prototypes/project-repos/fixtures";
const meta = {
title: "Proposals/Project repos/Configuration",
component: ProjectConfigurationPrototype,
tags: ["!autodocs"],
parameters: { layout: "fullscreen", viewport: { options: reviewViewports }, docs: { description: { component: "Full Onboarding configuration page, based on the Bull staging reference: sidebar, header, tabs, actual ProjectProperties, and the proposed source repo editor in the Codebase slot. Add/remove repos, save or discard, connect another GitHub account, and preserve existing text URLs. All writes and provider sign-in are simulated in Storybook." } } },
} satisfies Meta<typeof ProjectConfigurationPrototype>;
export default meta;
type Story = StoryObj<typeof meta>;
export const MultipleRepos: Story = { name: "01 · Multiple repos", args: { initialRepoIds: ["201", "202", "203"] } };
export const OneRepo: Story = { name: "02 · One repo", args: { initialRepoIds: ["201"] } };
export const NoRepos: Story = { name: "03 · No repos yet" };
export const NotConnected: Story = { name: "04 · Connect GitHub from configuration", args: { initialState: "disconnected" } };
export const ConnectAnotherAccount: Story = { name: "05 · Existing connector UI, preserve repo draft", args: { initialRepoIds: ["201"], startConnecting: true } };
export const LegacyUrlPreserved: Story = { name: "06 · Existing text URL + selected GitHub repos", args: { initialRepoIds: ["201"], legacyUrl: "https://github.com/papercool/legacy-service" } };
export const RepoLoadFailed: Story = { name: "07 · Existing repos survive load failure", args: { initialRepoIds: ["201", "202"], initialState: "error" } };
export const ManyRepos: Story = { name: "08 · Full page · forty repos", args: { initialRepoIds: crowdedRepoIds } };
export const ShortPage: Story = { ...ManyRepos, name: "09 · Full page · short desktop", globals: { viewport: { value: "short", isRotated: false } } };
export const MobilePage: Story = { ...MultipleRepos, name: "10 · Full configuration · mobile", globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileManyRepos: Story = { ...ManyRepos, name: "11 · Full page · forty repos on mobile", globals: { viewport: { value: "mobile", isRotated: false } } };
export const MobileShortPage: Story = { ...ManyRepos, name: "12 · Full page · short mobile", globals: { viewport: { value: "mobileShort", isRotated: false } } };