[codex] Bundle local branch fixes from PAP-10032 (#6604)

## Thinking Path

> - Paperclip orchestrates AI agents for zero-human companies.
> - This branch accumulated multiple already-tested control-plane,
adapter runtime, invite, workspace, plugin, and UI quality fixes on the
primary Paperclip checkout.
> - `origin/master` advanced while those commits were still local, so
the branch needed to be preserved and reconciled before review.
> - Splitting the branch commit-by-commit against the new base produced
overlapping conflicts with recently merged upstream PRs.
> - This pull request keeps the remaining branch as one standalone PR
because the final diff is 38 files after removing screenshot artifacts,
under Greptile's 100-file cap, and can be merged independently after
review.
> - The benefit is that none of the local work is lost, the branch is
now based on current `origin/master`, and reviewers can evaluate the
reconciled changes in one place.

## What Changed

- Merged the local accumulated branch with current `origin/master` and
resolved the invite-flow overlaps from the newer upstream companies
query helper.
- Preserved the local fixes for invite existing-member behavior, invite
link copy fallback, reusable workspace selection, worktree auth, static
SPA fallback, markdown wrapping, plugin slot registration, cloud
upstream UX/server polish, project sorting, and related tests.
- Removed screenshot artifacts from the PR per review request.
- Kept the PR under the requested file limit: 38 files changed, with no
`pnpm-lock.yaml` or `.github/workflows/*` changes.

## Verification

- `NODE_ENV=test pnpm exec vitest run
ui/src/pages/CompanyInvites.test.tsx ui/src/pages/InviteLanding.test.tsx
ui/src/pages/Projects.test.tsx ui/src/plugins/slots.test.ts
ui/src/components/MarkdownBody.test.tsx
server/src/__tests__/invite-accept-existing-member.test.ts
server/src/__tests__/static-index-html.test.ts
server/src/__tests__/execution-workspaces-service.test.ts
server/src/__tests__/better-auth.test.ts
server/src/__tests__/worktree-config.test.ts`
- `NODE_ENV=test pnpm --filter @paperclipai/ui typecheck`
- `NODE_ENV=test pnpm --filter @paperclipai/server typecheck`
- Confirmed `git diff --name-only origin/master...HEAD | wc -l` is `38`.
- Confirmed no PR diff entries match `pnpm-lock.yaml`,
`.github/workflows/*`, or `screenshots/*`.

## Risks

- Medium review risk because this is a bundled rescue PR rather than
several narrow feature PRs.
- Invite flow and company cache behavior overlapped with newer upstream
changes; the merge resolution intentionally keeps the shared
`companiesListQueryOptions` helper while preserving local
existing-member invite behavior.
- Visual review evidence is no longer attached in-repo because
screenshots were removed from this PR per review request.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

- OpenAI Codex, GPT-5-based coding agent, with repository tool access,
terminal execution, and git/GitHub CLI operations.

## 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 run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] UI screenshots were intentionally removed from this PR per review
request
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: CodexCoder <codexcoder@paperclip.local>
This commit is contained in:
Dotta 2026-05-25 07:25:26 -05:00 committed by GitHub
parent 96f0279e08
commit ece8a51e22
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 1715 additions and 134 deletions

View file

@ -1,6 +1,5 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
@ -65,6 +64,12 @@ vi.mock("@/lib/router", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
await callback();
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
}
async function flushReact() {
await act(async () => {
await Promise.resolve();

View file

@ -271,7 +271,7 @@ export function CompanyAccess() {
<div>
<h3 className="text-sm font-semibold">Pending human joins</h3>
<p className="text-sm text-muted-foreground">
Review human join requests before they become active company members.
Review pending join requests before they become active company members.
</p>
</div>
<Badge variant="outline">{pendingHumanJoinRequests.length} pending</Badge>

View file

@ -203,7 +203,11 @@ describe("CompanyInvites", () => {
expect(clipboardWriteTextMock).toHaveBeenCalledWith("https://paperclip.local/invite/new-token");
expect(container.textContent).toContain("Latest invite link");
expect(container.textContent).toContain("This URL includes the current Paperclip domain returned by the server.");
expect(container.textContent).toContain("https://paperclip.local/invite/new-token");
expect(container.querySelector('input[aria-label="Latest invite URL"]')).toHaveProperty(
"value",
"https://paperclip.local/invite/new-token",
);
expect(container.textContent).toContain("Copy link");
expect(container.textContent).toContain("Open invite");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Invite created",
@ -211,12 +215,12 @@ describe("CompanyInvites", () => {
tone: "success",
});
const inviteFieldButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("https://paperclip.local/invite/new-token"),
const copyLinkButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Copy link",
);
await act(async () => {
inviteFieldButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
copyLinkButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
@ -235,6 +239,61 @@ describe("CompanyInvites", () => {
});
});
it("falls back to selectable text when browser clipboard access is unavailable", async () => {
Object.defineProperty(globalThis.navigator, "clipboard", {
configurable: true,
value: undefined,
});
Object.defineProperty(document, "queryCommandSupported", {
configurable: true,
value: vi.fn((command: string) => command === "copy"),
});
Object.defineProperty(document, "execCommand", {
configurable: true,
value: vi.fn(() => true),
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<CompanyInvites />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const createButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Create invite",
);
await act(async () => {
createButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
await flushReact();
const inviteInput = container.querySelector('input[aria-label="Latest invite URL"]') as HTMLInputElement | null;
expect(inviteInput?.value).toBe("https://paperclip.local/invite/new-token");
expect(document.execCommand).toHaveBeenCalledWith("copy");
expect(pushToastMock).toHaveBeenCalledWith({
title: "Invite created",
body: "Invite ready below and copied to clipboard.",
tone: "success",
});
await act(async () => {
root.unmount();
});
});
it("ignores legacy cached invite arrays and refetches paginated history", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({

View file

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useInfiniteQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Check, ExternalLink, MailPlus } from "lucide-react";
import { Check, Copy, ExternalLink, MailPlus } from "lucide-react";
import { accessApi } from "@/api/access";
import { ApiError } from "@/api/client";
import { Button } from "@/components/ui/button";
@ -52,6 +52,7 @@ export function CompanyInvites() {
const [humanRole, setHumanRole] = useState<"owner" | "admin" | "operator" | "viewer">("operator");
const [latestInviteUrl, setLatestInviteUrl] = useState<string | null>(null);
const [latestInviteCopied, setLatestInviteCopied] = useState(false);
const latestInviteInputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
if (!latestInviteCopied) return;
@ -61,7 +62,12 @@ export function CompanyInvites() {
return () => window.clearTimeout(timeout);
}, [latestInviteCopied]);
async function copyText(text: string, unavailableBody: string) {
function selectLatestInviteUrl() {
latestInviteInputRef.current?.focus();
latestInviteInputRef.current?.select();
}
async function copyText(text: string, unavailableBody: string, afterFallback?: () => void) {
try {
if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
@ -71,6 +77,33 @@ export function CompanyInvites() {
// Fall through to the unavailable message below.
}
const canUseLegacyCopy =
typeof document !== "undefined" &&
typeof document.execCommand === "function" &&
(typeof document.queryCommandSupported !== "function" || document.queryCommandSupported("copy"));
if (canUseLegacyCopy) {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.setAttribute("readonly", "true");
textarea.style.position = "fixed";
textarea.style.top = "0";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.focus();
textarea.select();
textarea.setSelectionRange(0, textarea.value.length);
try {
const copied = document.execCommand("copy");
document.body.removeChild(textarea);
afterFallback?.();
if (copied) return true;
} catch {
document.body.removeChild(textarea);
}
}
afterFallback?.();
pushToast({
title: "Clipboard unavailable",
body: unavailableBody,
@ -79,6 +112,10 @@ export function CompanyInvites() {
return false;
}
async function copyInviteUrl(url: string) {
return copyText(url, "The invite URL is selected. Copy it manually from the field.", selectLatestInviteUrl);
}
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
@ -225,7 +262,7 @@ export function CompanyInvites() {
</fieldset>
<div className="rounded-lg border border-border px-4 py-3 text-sm text-muted-foreground">
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
</div>
<div className="flex flex-wrap items-center gap-3">
@ -251,17 +288,31 @@ export function CompanyInvites() {
This URL includes the current Paperclip domain returned by the server.
</div>
</div>
<button
type="button"
onClick={async () => {
const copied = await copyText(latestInviteUrl, "Copy the invite URL manually from the field below.");
setLatestInviteCopied(copied);
}}
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-left text-sm break-all transition-colors hover:bg-background"
>
{latestInviteUrl}
</button>
<label className="block space-y-1">
<span className="sr-only">Latest invite URL</span>
<input
ref={latestInviteInputRef}
readOnly
value={latestInviteUrl}
onFocus={(event) => event.currentTarget.select()}
onClick={(event) => event.currentTarget.select()}
className="w-full rounded-md border border-border bg-muted/60 px-3 py-2 text-sm text-foreground outline-none transition-colors selection:bg-primary selection:text-primary-foreground focus:border-ring"
aria-label="Latest invite URL"
/>
</label>
<div className="flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="outline"
onClick={async () => {
const copied = await copyInviteUrl(latestInviteUrl);
setLatestInviteCopied(copied);
}}
>
<Copy className="h-4 w-4" />
Copy link
</Button>
<Button size="sm" variant="outline" asChild>
<a href={latestInviteUrl} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />

View file

@ -6,6 +6,7 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { InviteLandingPage } from "./InviteLanding";
import { queryKeys } from "../lib/queryKeys";
const getInviteMock = vi.hoisted(() => vi.fn());
const acceptInviteMock = vi.hoisted(() => vi.fn());
@ -216,6 +217,11 @@ describe("InviteLandingPage", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
@ -302,6 +308,11 @@ describe("InviteLandingPage", () => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
@ -354,6 +365,11 @@ describe("InviteLandingPage", () => {
});
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({
companies: [],
unauthorized: false,
});
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
@ -422,7 +438,7 @@ describe("InviteLandingPage", () => {
});
});
it("keeps the waiting-for-approval state on refresh for an accepted invite", async () => {
it("auto-completes a previously accepted human invite after sign-in", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
@ -437,6 +453,12 @@ describe("InviteLandingPage", () => {
joinRequestStatus: "pending_approval",
joinRequestType: "human",
});
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "approved",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
@ -447,6 +469,58 @@ describe("InviteLandingPage", () => {
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.access.currentBoardAccess, {
userId: "user-1",
isInstanceAdmin: false,
companyIds: [],
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryState(queryKeys.access.currentBoardAccess)?.isInvalidated).toBe(true);
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
root.unmount();
});
});
it("asks unauthenticated users to sign in before completing an accepted human invite", async () => {
getInviteMock.mockResolvedValue({
id: "invite-1",
companyId: "company-1",
companyName: "Acme Robotics",
companyLogoUrl: "/api/invites/pcp_invite_test/logo",
companyBrandColor: "#114488",
inviteType: "company_join",
allowedJoinTypes: "human",
humanRole: "operator",
expiresAt: "2027-03-07T00:10:00.000Z",
inviteMessage: "Welcome aboard.",
joinRequestStatus: "pending_approval",
joinRequestType: "human",
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@ -465,14 +539,11 @@ describe("InviteLandingPage", () => {
});
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).not.toHaveBeenCalled();
expect(container.querySelector('[data-testid="invite-pending-approval"]')).not.toBeNull();
expect(container.textContent).toContain("Your request is still awaiting approval.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Members to approve your request.",
);
expect(container.querySelector('[data-testid="invite-inline-auth"]')).not.toBeNull();
expect(container.textContent).toContain("Create your account");
expect(container.querySelector('[data-testid="invite-pending-approval"]')).toBeNull();
await act(async () => {
root.unmount();
@ -551,6 +622,10 @@ describe("InviteLandingPage", () => {
});
expect(acceptInviteMock).not.toHaveBeenCalled();
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
expect(queryClient.getQueryData(queryKeys.companies.all)).toMatchObject({
companies: [{ id: "company-1", name: "Acme Robotics" }],
unauthorized: false,
});
expect(localStorage.getItem("paperclip:pending-invite-token")).toBeNull();
await act(async () => {
@ -558,6 +633,59 @@ describe("InviteLandingPage", () => {
});
});
it("shows invite details instead of auto-redirecting for signed-in existing members", async () => {
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
listCompaniesMock.mockResolvedValue([{ id: "company-1", name: "Acme Robotics" }]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Join Acme Robotics");
expect(container.textContent).toContain("Already in this company");
expect(container.textContent).toContain("This account already belongs to Acme Robotics.");
expect(acceptInviteMock).not.toHaveBeenCalled();
const openButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent === "Open company",
);
expect(openButton).not.toBeNull();
await act(async () => {
openButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(setSelectedCompanyIdMock).toHaveBeenCalledWith("company-1", { source: "manual" });
await act(async () => {
root.unmount();
});
});
it("falls back to the generated company icon when the invite logo fails to load", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
@ -594,6 +722,55 @@ describe("InviteLandingPage", () => {
});
});
it("normalizes the shared company cache envelope before checking membership", async () => {
acceptInviteMock.mockResolvedValue({
id: "join-1",
companyId: "company-1",
requestType: "human",
status: "pending_approval",
});
getSessionMock.mockResolvedValue({
session: { id: "session-1", userId: "user-1" },
user: {
id: "user-1",
name: "Jane Example",
email: "jane@example.com",
image: null,
},
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
queryClient.setQueryData(queryKeys.companies.all, {
companies: [],
unauthorized: false,
});
await act(async () => {
root.render(
<MemoryRouter initialEntries={["/invite/pcp_invite_test"]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/invite/:token" element={<InviteLandingPage />} />
</Routes>
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(acceptInviteMock).toHaveBeenCalledWith("pcp_invite_test", { requestType: "human" });
expect(container.textContent).toContain("Request to join Acme Robotics");
await act(async () => {
root.unmount();
});
});
it("waits for the membership check before showing invite acceptance to signed-in users", async () => {
let resolveCompanies: ((value: Array<{ id: string; name: string }>) => void) | null = null;
acceptInviteMock.mockResolvedValue({

View file

@ -266,9 +266,8 @@ export function InviteLandingPage() {
if (!list || !inviteQuery.data?.companyId) return;
if (list.some((c) => c.id === inviteQuery.data!.companyId)) {
clearPendingInviteToken(token);
navigate("/", { replace: true });
}
}, [companiesQuery.data, inviteQuery.data, token, navigate]);
}, [companiesQuery.data, inviteQuery.data, token]);
const invite = inviteQuery.data;
const isCheckingExistingMembership =
@ -287,6 +286,9 @@ export function InviteLandingPage() {
const requestedHumanRole = formatHumanRole(invite?.humanRole);
const inviteJoinRequestStatus = invite?.joinRequestStatus ?? null;
const inviteJoinRequestType = invite?.joinRequestType ?? null;
const canCompleteAcceptedHumanInvite =
inviteJoinRequestType === "human" &&
(inviteJoinRequestStatus === "pending_approval" || inviteJoinRequestStatus === "approved");
const requiresHumanAccount =
healthQuery.data?.deploymentMode === "authenticated" &&
!sessionQuery.data &&
@ -296,7 +298,7 @@ export function InviteLandingPage() {
Boolean(sessionQuery.data) &&
!showsAgentForm &&
invite?.inviteType !== "bootstrap_ceo" &&
!inviteJoinRequestStatus &&
(!inviteJoinRequestStatus || canCompleteAcceptedHumanInvite) &&
!isCheckingExistingMembership &&
!isCurrentMember &&
!result &&
@ -336,6 +338,7 @@ export function InviteLandingPage() {
const asBootstrap = isBootstrapAcceptancePayload(payload);
setResult({ kind: asBootstrap ? "bootstrap" : "join", payload });
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
if (invite?.companyId && isApprovedHumanJoinPayload(payload, showsAgentForm)) {
setSelectedCompanyId(invite.companyId, { source: "manual" });
@ -370,6 +373,7 @@ export function InviteLandingPage() {
setAuthFeedback(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);
if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) {
@ -404,10 +408,11 @@ export function InviteLandingPage() {
const joinButtonLabel = useMemo(() => {
if (!invite) return "Continue";
if (isCurrentMember) return "Open company";
if (invite.inviteType === "bootstrap_ceo") return "Accept invite";
if (showsAgentForm) return "Submit request";
return sessionQuery.data ? "Accept invite" : "Continue";
}, [invite, sessionQuery.data, showsAgentForm]);
}, [invite, isCurrentMember, sessionQuery.data, showsAgentForm]);
if (!token) {
return <div className="mx-auto max-w-xl py-10 text-sm text-destructive">Invalid invite token.</div>;
@ -442,7 +447,7 @@ export function InviteLandingPage() {
return <div className="mx-auto max-w-xl py-10 text-sm text-muted-foreground">Opening company...</div>;
}
if (inviteJoinRequestStatus === "pending_approval") {
if (inviteJoinRequestStatus === "pending_approval" && !canCompleteAcceptedHumanInvite) {
return (
<AwaitingJoinApprovalPanel
companyDisplayName={companyDisplayName}
@ -453,7 +458,7 @@ export function InviteLandingPage() {
);
}
if (inviteJoinRequestStatus) {
if (inviteJoinRequestStatus && !canCompleteAcceptedHumanInvite) {
return (
<div className="mx-auto max-w-xl py-10">
<div className="border border-border bg-card p-6" data-testid="invite-error">
@ -778,19 +783,21 @@ export function InviteLandingPage() {
<div className="space-y-4">
<div>
<h2 className="text-lg font-semibold">
{shouldAutoAcceptHumanInvite
? "Submitting join request"
{isCurrentMember
? "Already in this company"
: shouldAutoAcceptHumanInvite
? "Completing company access"
: invite.inviteType === "bootstrap_ceo"
? "Accept bootstrap invite"
: "Accept company invite"}
</h2>
<p className="mt-1 text-sm text-zinc-400">
{shouldAutoAcceptHumanInvite
? `Submitting your join request for ${companyDisplayName}.`
? `Granting your access to ${companyDisplayName}.`
: isCurrentMember
? `This account already belongs to ${companyDisplayName}.`
: `This will ${
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `submit or complete your join request for ${companyDisplayName}`
invite.inviteType === "bootstrap_ceo" ? "finish setting up Paperclip" : `grant or complete your access to ${companyDisplayName}`
}.`}
</p>
</div>
@ -802,8 +809,16 @@ export function InviteLandingPage() {
) : (
<Button
className="w-full rounded-none"
disabled={acceptMutation.isPending || isCurrentMember}
onClick={() => acceptMutation.mutate()}
disabled={acceptMutation.isPending}
onClick={() => {
if (isCurrentMember && invite.companyId) {
clearPendingInviteToken(token);
setSelectedCompanyId(invite.companyId, { source: "manual" });
navigate("/", { replace: true });
return;
}
acceptMutation.mutate();
}}
>
{acceptMutation.isPending ? "Working..." : joinButtonLabel}
</Button>

View file

@ -371,10 +371,10 @@ function AcceptInvitePreview({
<h3 className="text-lg font-semibold text-zinc-100">Accept company invite</h3>
<p className="mt-1 text-sm text-zinc-400">
{autoAccept
? "Submitting your join request for Acme Robotics."
? "Granting your access to Acme Robotics."
: isCurrentMember
? "This account already belongs to Acme Robotics."
: "This will submit or complete your join request for Acme Robotics."}
: "This will grant or complete your access to Acme Robotics."}
</p>
</div>
{error ? <p className="text-xs text-red-400">{error}</p> : null}
@ -572,7 +572,7 @@ function CompanyInvitesPreview() {
</fieldset>
<div className="rounded-xl border border-border px-4 py-3 text-sm text-muted-foreground">
Each invite link is single-use. The first successful use consumes the link and creates or reuses the matching join request before approval.
Each invite link is single-use. Human invitees get the selected role immediately after sign-in; agent invites still create a join request for approval.
</div>
<div className="flex flex-wrap items-center gap-3">

View file

@ -0,0 +1,164 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { Projects } from "./Projects";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewProject: mockOpenNewProject }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
function project(overrides: Partial<Project>): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function renderProjects(container: HTMLElement) {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
let root: Root | null = null;
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<Projects />
</QueryClientProvider>,
);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
});
return root;
}
function projectLinkNames(container: HTMLElement): string[] {
return Array.from(container.querySelectorAll<HTMLAnchorElement>("a[href^='/projects/']")).map((link) => {
const title = link.querySelector("span.truncate");
return title?.textContent ?? "";
});
}
describe("Projects", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.list.mockResolvedValue([
project({
id: "project-bravo",
urlKey: "bravo",
name: "Bravo",
description: null,
updatedAt: new Date("2026-05-02T00:00:00Z"),
}),
project({
id: "project-alpha",
urlKey: "alpha",
name: "Alpha",
description: "First project",
updatedAt: new Date("2026-05-01T00:00:00Z"),
}),
project({
id: "project-charlie",
urlKey: "charlie",
name: "Charlie",
description: null,
updatedAt: new Date("2026-05-03T00:00:00Z"),
}),
]);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
it("sorts projects by name by default and can switch sort mode", async () => {
root = await renderProjects(container);
expect(projectLinkNames(container)).toEqual(["Alpha", "Bravo", "Charlie"]);
const select = container.querySelector("select");
expect(select).not.toBeNull();
await act(async () => {
select!.value = "updated";
select!.dispatchEvent(new Event("change", { bubbles: true }));
});
expect(projectLinkNames(container)).toEqual(["Charlie", "Bravo", "Alpha"]);
});
it("reserves description line height for projects without descriptions", async () => {
root = await renderProjects(container);
const bravoLink = Array.from(container.querySelectorAll<HTMLAnchorElement>("a")).find((link) =>
link.textContent?.includes("Bravo"),
);
const hiddenDescriptionLine = bravoLink?.querySelector("p[aria-hidden='true']");
expect(hiddenDescriptionLine).not.toBeNull();
expect(hiddenDescriptionLine?.className).toContain("min-h-4");
});
});

View file

@ -1,5 +1,6 @@
import { useEffect, useMemo } from "react";
import { useEffect, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { projectsApi } from "../api/projects";
import { useCompany } from "../context/CompanyContext";
import { useDialogActions } from "../context/DialogContext";
@ -11,12 +12,67 @@ import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils";
import { Button } from "@/components/ui/button";
import { Hexagon, Plus } from "lucide-react";
import { ArrowUpDown, Hexagon, Plus } from "lucide-react";
type ProjectSortMode = "name" | "updated" | "targetDate" | "status";
const PROJECT_SORT_LABELS: Record<ProjectSortMode, string> = {
name: "Name",
updated: "Recently updated",
targetDate: "Target date",
status: "Status",
};
const PROJECT_STATUS_RANK: Record<Project["status"], number> = {
in_progress: 0,
planned: 1,
backlog: 2,
completed: 3,
cancelled: 4,
};
function projectTime(project: Project, field: "createdAt" | "updatedAt"): number {
const value = project[field];
const time = value instanceof Date ? value.getTime() : new Date(value).getTime();
return Number.isFinite(time) ? time : 0;
}
function compareProjectNames(left: Project, right: Project): number {
const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
}
function compareTargetDates(left: Project, right: Project): number {
if (!left.targetDate && !right.targetDate) return compareProjectNames(left, right);
if (!left.targetDate) return 1;
if (!right.targetDate) return -1;
const dateDiff = left.targetDate.localeCompare(right.targetDate);
return dateDiff !== 0 ? dateDiff : compareProjectNames(left, right);
}
function sortProjects(projects: Project[], sortMode: ProjectSortMode): Project[] {
return [...projects].sort((left, right) => {
if (sortMode === "updated") {
const updatedDiff = projectTime(right, "updatedAt") - projectTime(left, "updatedAt");
return updatedDiff !== 0 ? updatedDiff : compareProjectNames(left, right);
}
if (sortMode === "targetDate") {
return compareTargetDates(left, right);
}
if (sortMode === "status") {
const statusDiff = PROJECT_STATUS_RANK[left.status] - PROJECT_STATUS_RANK[right.status];
return statusDiff !== 0 ? statusDiff : compareProjectNames(left, right);
}
return compareProjectNames(left, right);
});
}
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const [sortMode, setSortMode] = useState<ProjectSortMode>("name");
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
@ -31,6 +87,10 @@ export function Projects() {
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
const sortedProjects = useMemo(
() => sortProjects(projects, sortMode),
[projects, sortMode],
);
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
@ -42,7 +102,22 @@ export function Projects() {
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="flex flex-wrap items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<ArrowUpDown className="h-4 w-4" />
<span>Sort</span>
<select
className="rounded-md border border-border bg-background px-2.5 py-1.5 text-sm text-foreground outline-none transition-colors hover:bg-accent/50 focus-visible:ring-2 focus-visible:ring-ring"
value={sortMode}
onChange={(event) => setSortMode(event.target.value as ProjectSortMode)}
>
{(Object.keys(PROJECT_SORT_LABELS) as ProjectSortMode[]).map((value) => (
<option key={value} value={value}>
{PROJECT_SORT_LABELS[value]}
</option>
))}
</select>
</label>
<Button size="sm" variant="outline" onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
@ -62,11 +137,12 @@ export function Projects() {
{projects.length > 0 && (
<div className="border border-border">
{projects.map((project) => (
{sortedProjects.map((project) => (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
to={projectUrl(project)}
trailing={
<div className="flex items-center gap-3">