[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
38 changed files with 1715 additions and 134 deletions
+3 -2
View File
@@ -30,10 +30,10 @@ import { Activity } from "./pages/Activity";
import { Inbox } from "./pages/Inbox";
import { CompanySettings } from "./pages/CompanySettings";
import { CompanyEnvironments } from "./pages/CompanyEnvironments";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { CloudUpstream } from "./pages/CloudUpstream";
import { CloudUpstreamUxLab } from "./pages/CloudUpstreamUxLab";
import { CompanySettingsPluginPage } from "./pages/CompanySettingsPluginPage";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./pages/CompanyAccess";
import { CompanyInvites } from "./pages/CompanyInvites";
import { CompanySkills } from "./pages/CompanySkills";
import { Secrets } from "./pages/Secrets";
@@ -72,6 +72,7 @@ function boardRoutes() {
<Route path="companies" element={<Companies />} />
<Route path="company/settings" element={<CompanySettings />} />
<Route path="company/settings/environments" element={<CompanyEnvironments />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
<Route path="company/settings/members" element={<CompanyAccess />} />
<Route path="company/settings/access" element={<CompanyAccessLegacyRoute />} />
<Route path="company/settings/cloud-upstream" element={<CloudUpstream />} />
@@ -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 { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -10,10 +9,10 @@ const sidebarNavItemMock = vi.hoisted(() => vi.fn());
const mockSidebarBadgesApi = vi.hoisted(() => ({
get: vi.fn(),
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({
@@ -65,22 +64,28 @@ vi.mock("@/api/sidebarBadges", () => ({
sidebarBadgesApi: mockSidebarBadgesApi,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
// 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 () => {
for (let i = 0; i < 3; i += 1) {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
}
describe("CompanySettingsSidebar", () => {
@@ -95,6 +100,9 @@ describe("CompanySettingsSidebar", () => {
failedRuns: 0,
joinRequests: 2,
});
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: false,
});
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
@@ -130,6 +138,7 @@ describe("CompanySettingsSidebar", () => {
expect(container.textContent).toContain("Company Settings");
expect(container.textContent).toContain("General");
expect(container.textContent).toContain("Environments");
expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Members");
expect(container.textContent).not.toContain("Cloud upstream");
expect(container.textContent).toContain("Invites");
@@ -176,6 +185,38 @@ describe("CompanySettingsSidebar", () => {
});
});
it("shows cloud upstream only when cloud sync is enabled", async () => {
mockInstanceSettingsApi.getExperimental.mockResolvedValue({
enableCloudSync: true,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsSidebar />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Cloud upstream");
expect(sidebarNavItemMock).toHaveBeenCalledWith(
expect.objectContaining({
to: "/company/settings/cloud-upstream",
label: "Cloud upstream",
end: true,
}),
);
await act(async () => {
root.unmount();
});
});
it("renders company settings pages contributed by ready plugins", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
+9 -2
View File
@@ -12,6 +12,7 @@ interface EntityRowProps {
to?: string;
onClick?: () => void;
className?: string;
reserveSubtitleSpace?: boolean;
}
export function EntityRow({
@@ -24,6 +25,7 @@ export function EntityRow({
to,
onClick,
className,
reserveSubtitleSpace,
}: EntityRowProps) {
const isClickable = !!(to || onClick);
const classes = cn(
@@ -45,8 +47,13 @@ export function EntityRow({
)}
<span className="truncate">{title}</span>
</div>
{subtitle && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{subtitle}</p>
{(subtitle || reserveSubtitleSpace) && (
<p
className={cn("text-xs text-muted-foreground truncate mt-0.5 min-h-4", !subtitle && "invisible")}
aria-hidden={!subtitle}
>
{subtitle}
</p>
)}
</div>
{trailing && <div className="flex items-center gap-2 shrink-0">{trailing}</div>}
+2
View File
@@ -450,8 +450,10 @@ describe("MarkdownBody", () => {
expect(html).toContain("paperclip-markdown-codeblock");
expect(html).toContain("paperclip-markdown-codeblock-actions");
expect(html).toContain("position:absolute;top:0.4rem;right:0.4rem;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-wrap");
expect(html).toContain('aria-label="Wrap lines"');
expect(html).toContain("position:static;opacity:1;display:inline-flex");
expect(html).toContain("paperclip-markdown-codeblock-copy");
expect(html).toContain('aria-label="Copy code"');
expect(html).toContain("lucide-copy");
+42 -1
View File
@@ -84,6 +84,39 @@ const scrollableBlockStyle: React.CSSProperties = {
overflowX: "auto",
};
const codeBlockActionsStyle: React.CSSProperties = {
position: "absolute",
top: "0.4rem",
right: "0.4rem",
display: "inline-flex",
alignItems: "center",
gap: "0.25rem",
};
const codeBlockActionStyle: React.CSSProperties = {
position: "static",
opacity: 1,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: "0.25rem",
minHeight: "1.55rem",
padding: "0.2rem 0.4rem",
borderRadius: "calc(var(--radius) - 4px)",
border: "1px solid color-mix(in oklab, var(--foreground) 14%, transparent)",
backgroundColor: "color-mix(in oklab, var(--muted) 92%, var(--background) 8%)",
color: "var(--muted-foreground)",
fontSize: "0.7rem",
lineHeight: 1,
cursor: "pointer",
};
const codeBlockWrapActionStyle: React.CSSProperties = {
...codeBlockActionStyle,
width: "1.55rem",
paddingInline: 0,
};
const tableCellWrapStyle: React.CSSProperties = {
overflowWrap: "anywhere",
wordBreak: "normal",
@@ -426,6 +459,7 @@ function CodeBlock({
</pre>
<div
className="paperclip-markdown-codeblock-actions"
style={codeBlockActionsStyle}
data-active={copied || failed || wrapLines || undefined}
>
<button
@@ -434,11 +468,17 @@ function CodeBlock({
aria-label={wrapLabel}
title={wrapLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-wrap"
style={wrapLines
? {
...codeBlockWrapActionStyle,
borderColor: "color-mix(in oklab, var(--primary) 38%, transparent)",
color: "var(--primary)",
}
: codeBlockWrapActionStyle}
aria-pressed={wrapLines}
data-active={wrapLines || undefined}
>
<WrapText aria-hidden="true" className="h-3.5 w-3.5" />
<span className="paperclip-markdown-codeblock-action-label">{wrapLabel}</span>
</button>
<button
type="button"
@@ -446,6 +486,7 @@ function CodeBlock({
aria-label="Copy code"
title={copyLabel}
className="paperclip-markdown-codeblock-action paperclip-markdown-codeblock-copy"
style={codeBlockActionStyle}
data-copied={copied || undefined}
data-failed={failed || undefined}
>
+23 -4
View File
@@ -52,6 +52,7 @@ const mockIssuesApi = vi.hoisted(() => ({
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
list: vi.fn(),
listSummaries: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({
@@ -310,7 +311,9 @@ describe("NewIssueDialog", () => {
mockIssuesApi.create.mockReset();
mockIssuesApi.upsertDocument.mockReset();
mockIssuesApi.uploadAttachment.mockReset();
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockExecutionWorkspacesApi.list.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockReset();
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([]);
mockProjectsApi.list.mockResolvedValue([
{
id: "project-1",
@@ -382,13 +385,15 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
@@ -406,6 +411,15 @@ describe("NewIssueDialog", () => {
const { root } = renderDialog(container);
await flush();
await waitForAssertion(() => {
expect(mockExecutionWorkspacesApi.listSummaries).toHaveBeenCalledWith("company-1", {
projectId: "project-1",
projectWorkspaceId: undefined,
reuseEligible: true,
});
});
expect(mockExecutionWorkspacesApi.list).not.toHaveBeenCalled();
const submitButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.includes("Create Sub-Issue"));
expect(submitButton).not.toBeUndefined();
@@ -494,7 +508,7 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "PAP-100",
@@ -502,6 +516,7 @@ describe("NewIssueDialog", () => {
status: "active",
branchName: "feature/pap-100",
cwd: "/tmp/workspace-1",
projectWorkspaceId: "project-workspace-2",
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
]);
@@ -809,21 +824,25 @@ describe("NewIssueDialog", () => {
},
},
]);
mockExecutionWorkspacesApi.list.mockResolvedValue([
mockExecutionWorkspacesApi.listSummaries.mockResolvedValue([
{
id: "workspace-1",
name: "Parent workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-1",
cwd: "/tmp/workspace-1",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:00:00.000Z"),
},
{
id: "workspace-2",
name: "Other workspace",
mode: "isolated_workspace",
status: "active",
branchName: "feature/pap-2",
cwd: "/tmp/workspace-2",
projectWorkspaceId: null,
lastUsedAt: new Date("2026-04-06T16:01:00.000Z"),
},
]);
+2 -2
View File
@@ -469,13 +469,13 @@ export function NewIssueDialog() {
enabled: !!effectiveCompanyId && newIssueOpen,
});
const { data: reusableExecutionWorkspaces } = useQuery({
queryKey: queryKeys.executionWorkspaces.list(effectiveCompanyId!, {
queryKey: queryKeys.executionWorkspaces.summaryList(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
}),
queryFn: () =>
executionWorkspacesApi.list(effectiveCompanyId!, {
executionWorkspacesApi.listSummaries(effectiveCompanyId!, {
projectId,
projectWorkspaceId: projectWorkspaceId || undefined,
reuseEligible: true,
+4 -1
View File
@@ -736,7 +736,9 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
.paperclip-markdown-codeblock-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
min-height: 1.55rem;
padding: 0.2rem 0.4rem;
border-radius: calc(var(--radius) - 4px);
border: 1px solid color-mix(in oklab, var(--foreground) 14%, transparent);
@@ -745,7 +747,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
font-size: 0.7rem;
line-height: 1;
cursor: pointer;
transition: background-color 0.12s ease, color 0.12s ease;
transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.paperclip-markdown-codeblock:hover .paperclip-markdown-codeblock-actions,
@@ -761,6 +763,7 @@ a.paperclip-mention-chip[data-mention-kind="agent"]::before {
.paperclip-markdown-codeblock-action[data-active],
.paperclip-markdown-codeblock-action[data-copied] {
border-color: color-mix(in oklab, var(--primary) 38%, transparent);
color: var(--primary);
}
+6 -1
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();
+1 -1
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>
+63 -4
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({
+65 -14
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" />
+184 -7
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({
+27 -12
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>
+3 -3
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">
+164
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");
});
});
+80 -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">
+87
View File
@@ -0,0 +1,87 @@
// @vitest-environment jsdom
import { createElement } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import {
PluginSlotMount,
_collectRegisterableExportNamesForTests,
_resetPluginModuleLoader,
registerPluginWebComponent,
type ResolvedPluginSlot,
} from "./slots";
let roots: Root[] = [];
afterEach(() => {
for (const root of roots) {
flushSync(() => {
root.unmount();
});
}
roots = [];
_resetPluginModuleLoader();
});
describe("plugin slot export registration", () => {
it("keeps declared missing exports visible for diagnostics", () => {
const exports = _collectRegisterableExportNamesForTests(
{ Page: () => null },
new Set(["Page", "MissingRouteSidebar"]),
);
expect([...exports]).toEqual(["Page", "MissingRouteSidebar"]);
});
it("registers component-like module exports even when the current contribution did not declare them", () => {
const exports = _collectRegisterableExportNamesForTests(
{
Page: () => null,
RouteSidebar: () => null,
webComponentTag: "paperclip-widget",
metadata: { ignored: true },
count: 1,
default: () => null,
},
new Set(["Page"]),
);
expect(exports).toEqual(new Set(["Page", "RouteSidebar", "webComponentTag"]));
});
it("updates an already-mounted placeholder when the slot export registers later", async () => {
const container = document.createElement("div");
document.body.appendChild(container);
const root = createRoot(container);
roots.push(root);
const slot: ResolvedPluginSlot = {
type: "routeSidebar",
id: "content-machine-sidebar",
displayName: "Content",
exportName: "ContentMachineRouteSidebar",
routePath: "content-machine",
pluginId: "content-machine-plugin",
pluginKey: "content-machine",
pluginDisplayName: "Content Machine",
pluginVersion: "1.0.0",
};
flushSync(() => {
root.render(createElement(PluginSlotMount, {
slot,
context: { companyId: "company-1", companyPrefix: "PAP" },
missingBehavior: "placeholder",
}));
});
expect(container.textContent).toContain("Content Machine: Content");
flushSync(() => {
registerPluginWebComponent("content-machine", "ContentMachineRouteSidebar", "paperclip-test-sidebar");
});
expect(container.textContent).not.toContain("Content Machine: Content");
expect(container.querySelector("paperclip-test-sidebar")).not.toBeNull();
});
});
+43 -1
View File
@@ -124,11 +124,30 @@ type UsePluginSlotsResult = {
* Keys are `${pluginKey}:${exportName}` to match manifest slot declarations.
*/
const registry = new Map<string, RegisteredPluginComponent>();
const registryListeners = new Set<() => void>();
function buildRegistryKey(pluginKey: string, exportName: string): string {
return `${pluginKey}:${exportName}`;
}
function notifyRegistryListeners(): void {
for (const listener of registryListeners) {
listener();
}
}
function usePluginRegistrySubscription(): void {
const [, forceRerender] = useState(0);
useEffect(() => {
const listener = () => forceRerender((tick) => tick + 1);
registryListeners.add(listener);
return () => {
registryListeners.delete(listener);
};
}, []);
}
function requiresEntityType(slotType: PluginUiSlotType): boolean {
return slotType === "detailTab" || slotType === "taskDetailView" || slotType === "contextMenuItem" || slotType === "commentAnnotation" || slotType === "commentContextMenuItem" || slotType === "projectSidebarItem" || slotType === "toolbarButton";
}
@@ -150,6 +169,7 @@ export function registerPluginReactComponent(
kind: "react",
component,
});
notifyRegistryListeners();
}
/**
@@ -164,6 +184,7 @@ export function registerPluginWebComponent(
kind: "web-component",
tagName,
});
notifyRegistryListeners();
}
function resolveRegisteredComponent(slot: ResolvedPluginSlot): RegisteredPluginComponent | null {
@@ -177,6 +198,24 @@ export function resolveRegisteredPluginComponent(
return registry.get(buildRegistryKey(pluginKey, exportName)) ?? null;
}
function isRegisterablePluginExport(exported: unknown): boolean {
return typeof exported === "function" || typeof exported === "string";
}
function collectRegisterableExportNames(
mod: Record<string, unknown>,
declaredExports: Set<string>,
): Set<string> {
const exportNames = new Set(declaredExports);
for (const [exportName, exported] of Object.entries(mod)) {
if (exportName === "default") continue;
if (isRegisterablePluginExport(exported)) {
exportNames.add(exportName);
}
}
return exportNames;
}
// ---------------------------------------------------------------------------
// Plugin module dynamic import loader
// ---------------------------------------------------------------------------
@@ -471,7 +510,8 @@ async function loadPluginModule(contribution: PluginUiContribution): Promise<voi
}
}
for (const exportName of declaredExports) {
const exportNames = collectRegisterableExportNames(mod, declaredExports);
for (const exportName of exportNames) {
const exported = mod[exportName];
if (exported === undefined) {
console.warn(
@@ -783,6 +823,7 @@ export function PluginSlotMount({
className,
missingBehavior = "hidden",
}: PluginSlotMountProps) {
usePluginRegistrySubscription();
const [, forceRerender] = useState(0);
const component = resolveRegisteredComponent(slot);
@@ -910,3 +951,4 @@ export function _resetPluginModuleLoader(): void {
export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey;
export const _createReactShimSourceForTests = createReactShimSource;
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;
export const _collectRegisterableExportNamesForTests = collectRegisterableExportNames;