Merge upstream/master (53 commits) into local
Build: Production / build (push) Failing after 13m4s

Resolved conflicts:
- ui CompanySettingsSidebar.tsx: keep both Secrets (local) and Cloud upstream (master) nav items
- ui CompanySettingsNav.tsx + test: take master's cloud-upstream/members (drops deprecated `access` tab now consolidated into `members`)
- server plugin-worker-manager.ts: take master's 15min RPC timeout cap
- pnpm-lock.yaml: regenerated via `pnpm install` against merged package.json files

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 08:01:31 -04:00
536 changed files with 60296 additions and 2542 deletions
+175 -3
View File
@@ -36,15 +36,21 @@ import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { MarkdownBody } from "../components/MarkdownBody";
import { CopyText } from "../components/CopyText";
import { EntityRow } from "../components/EntityRow";
import { MembershipAction } from "../components/MembershipAction";
import { Identity } from "../components/Identity";
import { PageSkeleton } from "../components/PageSkeleton";
import { RunButton, PauseResumeButton } from "../components/AgentActionButtons";
import { BudgetPolicyCard } from "../components/BudgetPolicyCard";
import { FileTree, buildFileTree } from "../components/FileTree";
import { ScrollToBottom } from "../components/ScrollToBottom";
import { SourceResolvedFoldCallout } from "../components/SourceResolvedFoldCallout";
import { SourceResolvedFoldBadge } from "../components/SourceResolvedFoldBadge";
import { readSourceResolvedWatchdogFold } from "../lib/source-resolved-watchdog-fold";
import { buildSameOriginWebSocketUrl } from "../lib/websocket-url";
import { formatCents, formatDate, relativeTime, formatTokens, visibleRunCostUsd } from "../lib/utils";
import { cn } from "../lib/utils";
import { describeRunRetryState } from "../lib/runRetryState";
import { buildDuplicateAgentPayload, duplicateAgentName, type DuplicateInstructionsBundle } from "../lib/duplicate-agent-payload";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs } from "@/components/ui/tabs";
@@ -83,6 +89,8 @@ import { RunTranscriptView, type TranscriptMode } from "../components/transcript
import {
isUuidLike,
type Agent,
type AgentInstructionsBundle,
type AgentInstructionsFileSummary,
type AgentSkillEntry,
type AgentSkillSnapshot,
type AgentDetail as AgentDetailRecord,
@@ -95,12 +103,45 @@ import {
} from "@paperclipai/shared";
import { redactHomePathUserSegments, redactHomePathUserSegmentsInValue } from "@paperclipai/adapter-utils";
import { agentRouteRef } from "../lib/utils";
import {
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import {
applyAgentSkillSnapshot,
arraysEqual,
isReadOnlyUnmanagedSkillEntry,
} from "../lib/agent-skills-state";
async function loadDuplicateInstructionsBundle(
agentId: string,
companyId?: string,
): Promise<DuplicateInstructionsBundle | null> {
const bundle = await agentsApi.instructionsBundle(agentId, companyId);
const files: Record<string, string> = {};
for (const summary of bundle.files) {
const path = duplicateInstructionFilePath(bundle, summary);
if (!path) continue;
const file = await agentsApi.instructionsFile(agentId, summary.path, companyId);
files[path] = file.content;
}
const entryFile = Object.prototype.hasOwnProperty.call(files, bundle.entryFile)
? bundle.entryFile
: Object.keys(files)[0] ?? "AGENTS.md";
return Object.keys(files).length > 0 ? { entryFile, files } : null;
}
function duplicateInstructionFilePath(
_bundle: AgentInstructionsBundle,
summary: AgentInstructionsFileSummary,
): string | null {
if (summary.deprecated || summary.virtual) return null;
return summary.path;
}
const runStatusIcons: Record<string, { icon: typeof CheckCircle2; color: string }> = {
succeeded: { icon: CheckCircle2, color: "text-green-600 dark:text-green-400" },
failed: { icon: XCircle, color: "text-red-600 dark:text-red-400" },
@@ -635,10 +676,12 @@ export function AgentDetail() {
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { closePanel } = usePanel();
const { openNewIssue } = useDialogActions();
const { pushToast } = useToastActions();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const navigate = useNavigate();
const [actionError, setActionError] = useState<string | null>(null);
const [dismissedLeftAgentIds, setDismissedLeftAgentIds] = useState<Set<string>>(() => new Set());
const [moreOpen, setMoreOpen] = useState(false);
const activeView = urlRunId ? "runs" as AgentDetailView : parseAgentDetailView(urlTab ?? null);
const needsDashboardData = activeView === "dashboard";
@@ -669,6 +712,11 @@ export function AgentDetail() {
const canonicalAgentRef = agent ? agentRouteRef(agent) : routeAgentRef;
const agentLookupRef = agent?.id ?? routeAgentRef;
const resolvedAgentId = agent?.id ?? null;
const membershipsQuery = useResourceMemberships(resolvedCompanyId);
const membershipMutation = useResourceMembershipMutation(resolvedCompanyId);
const agentMembershipState = resolvedAgentId
? resourceMembershipState(membershipsQuery.data, "agent", resolvedAgentId)
: "joined";
const { data: runtimeState } = useQuery({
queryKey: queryKeys.agents.runtimeState(resolvedAgentId ?? routeAgentRef),
@@ -805,6 +853,57 @@ export function AgentDetail() {
},
});
const duplicateAgent = useMutation({
mutationFn: async () => {
if (!agent?.id || !resolvedCompanyId) {
throw new Error("Agent is not ready to duplicate");
}
const instructionsBundle = await loadDuplicateInstructionsBundle(agent.id, resolvedCompanyId);
const payload = buildDuplicateAgentPayload(agent, instructionsBundle);
try {
return await agentsApi.create(resolvedCompanyId, payload);
} catch (error) {
if (error instanceof ApiError && error.status === 409 && error.message.includes("requires board approval")) {
const hire = await agentsApi.hire(resolvedCompanyId, payload);
return hire.agent;
}
throw error;
}
},
onSuccess: async (createdAgent) => {
setActionError(null);
if (resolvedCompanyId) {
await queryClient.invalidateQueries({ queryKey: queryKeys.agents.list(resolvedCompanyId) });
}
pushToast({
title: "Agent duplicated",
body: createdAgent.name,
tone: "success",
});
navigate(`/agents/${agentRouteRef(createdAgent)}/dashboard`);
},
onError: (err) => {
const message = err instanceof Error ? err.message : "Failed to duplicate agent";
setActionError(message);
pushToast({
title: "Could not duplicate agent",
body: message,
tone: "error",
});
},
});
const handleDuplicateAgent = useCallback(() => {
if (!agent || duplicateAgent.isPending) return;
const nextName = duplicateAgentName(agent.name);
const confirmed = window.confirm(`Duplicate ${agent.name} as ${nextName}?`);
setMoreOpen(false);
if (!confirmed) return;
duplicateAgent.mutate();
}, [agent, duplicateAgent]);
const budgetMutation = useMutation({
mutationFn: (amount: number) =>
budgetsApi.upsertPolicy(resolvedCompanyId!, {
@@ -897,6 +996,16 @@ export function AgentDetail() {
return () => closePanel();
}, [closePanel]);
useEffect(() => {
if (!resolvedAgentId || agentMembershipState !== "joined") return;
setDismissedLeftAgentIds((current) => {
if (!current.has(resolvedAgentId)) return current;
const next = new Set(current);
next.delete(resolvedAgentId);
return next;
});
}, [resolvedAgentId, agentMembershipState]);
useBeforeUnload(
useCallback((event) => {
if (!configDirty) return;
@@ -913,9 +1022,48 @@ export function AgentDetail() {
}
const isPendingApproval = agent.status === "pending_approval";
const showConfigActionBar = (activeView === "configuration" || activeView === "instructions") && (configDirty || configSaving);
const showLeftAgentNotice = agentMembershipState === "left" && !dismissedLeftAgentIds.has(agent.id);
const agentMembershipPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id;
return (
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
{showLeftAgentNotice ? (
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-100">
<p className="min-w-0 flex-1">
You left this agent. It no longer appears in your sidebar.
</p>
<MembershipAction
compact
state="left"
pending={agentMembershipPending}
pendingState={agentMembershipPending ? membershipMutation.variables?.state : null}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
})}
/>
<button
type="button"
className="h-6 w-6 shrink-0 text-yellow-100/70 hover:text-yellow-100"
aria-label="Dismiss agent membership notice"
onClick={() => setDismissedLeftAgentIds((current) => new Set(current).add(agent.id))}
>
×
</button>
</div>
) : null}
{/* Header */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0">
@@ -977,6 +1125,18 @@ export function AgentDetail() {
</Button>
</PopoverTrigger>
<PopoverContent className="w-44 p-1" align="end">
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
disabled={duplicateAgent.isPending}
onClick={handleDuplicateAgent}
>
{duplicateAgent.isPending ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<Copy className="h-3 w-3" />
)}
Duplicate Agent
</button>
<button
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
onClick={() => {
@@ -1615,7 +1775,9 @@ function ConfigurationTab({
? "Enabled automatically while this agent can create new agents."
: taskAssignSource === "explicit_grant"
? "Enabled via explicit company permission grant."
: "Disabled unless explicitly granted.";
: taskAssignSource === "simple_default"
? "Enabled by simple company-wide task assignment defaults."
: "Disabled unless explicitly granted.";
return (
<div className="space-y-6">
@@ -2899,6 +3061,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
const summary = run.resultJson
? String((run.resultJson as Record<string, unknown>).summary ?? (run.resultJson as Record<string, unknown>).result ?? "")
: run.error ?? "";
const sourceResolvedFold = readSourceResolvedWatchdogFold(run.resultJson);
return (
<Link
@@ -2922,6 +3085,7 @@ function RunListItem({ run, isSelected, agentId }: { run: HeartbeatRun; isSelect
)}>
{sourceLabels[run.invocationSource] ?? run.invocationSource}
</span>
{sourceResolvedFold ? <SourceResolvedFoldBadge showIcon={false} className="shrink-0 text-[10px] py-0" /> : null}
<span className="ml-auto text-[11px] text-muted-foreground shrink-0">
{relativeTime(run.createdAt)}
</span>
@@ -3474,6 +3638,13 @@ function RunDetail({ run: initialRun, agentRouteId, adapterType, adapterConfig }
</div>
)}
{(() => {
const fold = readSourceResolvedWatchdogFold(run.resultJson);
if (!fold) return null;
if (run.status === "failed" || run.status === "timed_out") return null;
return <SourceResolvedFoldCallout fold={fold} finalizedAt={run.finishedAt} />;
})()}
{/* Log viewer */}
<LogViewer run={run} adapterType={adapterType} />
<ScrollToBottom />
@@ -3756,8 +3927,9 @@ function LogViewer({ run, adapterType }: { run: HeartbeatRun; adapterType: strin
const connect = () => {
if (closed) return;
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
const url = `${protocol}://${window.location.host}/api/companies/${encodeURIComponent(run.companyId)}/events/ws`;
const url = buildSameOriginWebSocketUrl(
`/api/companies/${encodeURIComponent(run.companyId)}/events/ws`,
);
socket = new WebSocket(url);
socket.onopen = () => {
+33 -2
View File
@@ -1,11 +1,12 @@
// @vitest-environment jsdom
import { act } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../context/ToastContext";
import { Agents } from "./Agents";
const mockAgentsApi = vi.hoisted(() => ({
@@ -17,6 +18,11 @@ const mockHeartbeatsApi = vi.hoisted(() => ({
liveRunsForCompany: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateAgent: vi.fn(),
}));
const mockOpenNewAgent = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
@@ -52,6 +58,10 @@ vi.mock("../api/heartbeats", () => ({
heartbeatsApi: mockHeartbeatsApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
vi.mock("../adapters/adapter-display-registry", () => ({
getAdapterLabel: (type: string) => type,
}));
@@ -59,6 +69,14 @@ vi.mock("../adapters/adapter-display-registry", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeAgent(overrides: Partial<Agent>): Agent {
return {
id: "agent-1",
@@ -120,6 +138,17 @@ describe("Agents", () => {
},
]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockResourceMembershipsApi.listMine.mockResolvedValue({
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
});
mockResourceMembershipsApi.updateAgent.mockResolvedValue({
resourceType: "agent",
resourceId: "agent-1",
state: "left",
updatedAt: new Date("2026-01-02T00:00:00Z"),
});
});
afterEach(async () => {
@@ -140,7 +169,9 @@ describe("Agents", () => {
await act(async () => {
root!.render(
<QueryClientProvider client={queryClient}>
<Agents />
<ToastProvider>
<Agents />
</ToastProvider>
</QueryClientProvider>,
);
});
+92 -4
View File
@@ -9,6 +9,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { useSidebar } from "../context/SidebarContext";
import { queryKeys } from "../lib/queryKeys";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
import { EntityRow } from "../components/EntityRow";
import { EmptyState } from "../components/EmptyState";
@@ -19,6 +20,11 @@ import { Tabs } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Bot, Plus, List, GitBranch, SlidersHorizontal } from "lucide-react";
import { AGENT_ROLE_LABELS, type Agent } from "@paperclipai/shared";
import {
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
@@ -93,6 +99,8 @@ export function Agents() {
enabled: !!selectedCompanyId,
refetchInterval: 15_000,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
// Map agentId -> first live run + live run count
const liveRunByAgent = useMemo(() => {
@@ -231,7 +239,11 @@ export function Agents() {
title={agent.name}
subtitle={`${roleLabels[agent.role] ?? agent.role}${agent.title ? ` - ${agent.title}` : ""}`}
to={agentUrl(agent)}
className={agent.pausedAt && tab !== "paused" ? "opacity-50" : ""}
className={cn(
"group",
agent.pausedAt && tab !== "paused" ? "opacity-50" : "",
resourceMembershipState(membershipsQuery.data, "agent", agent.id) === "left" ? "text-foreground/55" : "",
)}
leading={
<span className="relative flex h-2.5 w-2.5">
<span
@@ -276,6 +288,34 @@ export function Agents() {
<StatusBadge status={agent.status} />
</span>
</div>
<MembershipAction
state={resourceMembershipState(membershipsQuery.data, "agent", agent.id)}
pending={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
}
pendingState={
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === agent.id
? membershipMutation.variables.state
: null
}
resourceName={agent.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: agent.id,
resourceName: agent.name,
state: "left",
})}
/>
</div>
}
/>
@@ -294,7 +334,16 @@ export function Agents() {
{effectiveView === "org" && filteredOrg.length > 0 && (
<div className="border border-border py-1">
{filteredOrg.map((node) => (
<OrgTreeNode key={node.id} node={node} depth={0} agentMap={agentMap} liveRunByAgent={liveRunByAgent} tab={tab} />
<OrgTreeNode
key={node.id}
node={node}
depth={0}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
tab={tab}
memberships={membershipsQuery.data}
membershipMutation={membershipMutation}
/>
))}
</div>
)}
@@ -320,14 +369,22 @@ function OrgTreeNode({
agentMap,
liveRunByAgent,
tab,
memberships,
membershipMutation,
}: {
node: OrgNode;
depth: number;
agentMap: Map<string, Agent>;
liveRunByAgent: Map<string, { runId: string; liveCount: number }>;
tab: FilterTab;
memberships: ReturnType<typeof useResourceMemberships>["data"];
membershipMutation: ReturnType<typeof useResourceMembershipMutation>;
}) {
const agent = agentMap.get(node.id);
const membershipState = resourceMembershipState(memberships, "agent", node.id);
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "agent" &&
membershipMutation.variables.resourceId === node.id;
const statusColor = agentStatusDot[node.status] ?? agentStatusDotDefault;
@@ -335,7 +392,11 @@ function OrgTreeNode({
<div style={{ paddingLeft: depth * 24 }}>
<Link
to={agent ? agentUrl(agent) : `/agents/${node.id}`}
className={cn("flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit", agent?.pausedAt && tab !== "paused" && "opacity-50")}
className={cn(
"group flex items-center gap-3 px-3 py-2 hover:bg-accent/30 transition-colors w-full text-left no-underline text-inherit",
agent?.pausedAt && tab !== "paused" && "opacity-50",
membershipState === "left" && "text-foreground/55",
)}
>
<span className="relative flex h-2.5 w-2.5 shrink-0">
<span className={`absolute inline-flex h-full w-full rounded-full ${statusColor}`} />
@@ -387,12 +448,39 @@ function OrgTreeNode({
<StatusBadge status={node.status} />
</span>
</div>
<MembershipAction
state={membershipState}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
resourceName={node.name}
onJoin={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "agent",
resourceId: node.id,
resourceName: node.name,
state: "left",
})}
/>
</div>
</Link>
{node.reports && node.reports.length > 0 && (
<div className="border-l border-border/50 ml-4">
{node.reports.map((child) => (
<OrgTreeNode key={child.id} node={child} depth={depth + 1} agentMap={agentMap} liveRunByAgent={liveRunByAgent} tab={tab} />
<OrgTreeNode
key={child.id}
node={child}
depth={depth + 1}
agentMap={agentMap}
liveRunByAgent={liveRunByAgent}
tab={tab}
memberships={memberships}
membershipMutation={membershipMutation}
/>
))}
</div>
)}
+413
View File
@@ -0,0 +1,413 @@
// @vitest-environment jsdom
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CloudUpstreamRun, CloudUpstreamsState } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CloudUpstream, buildActivationRows } from "./CloudUpstream";
const mockCloudUpstreamsApi = vi.hoisted(() => ({
list: vi.fn(),
startConnect: vi.fn(),
finishConnect: vi.fn(),
preview: vi.fn(),
createRun: vi.fn(),
getRun: vi.fn(),
cancelRun: vi.fn(),
activateEntities: vi.fn(),
}));
const mockInstanceSettingsApi = vi.hoisted(() => ({
getExperimental: vi.fn(),
}));
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockCompanyState = vi.hoisted(() => ({
selectedCompany: { id: "company-1", name: "Paperclip", issuePrefix: "PAP" } as
| { id: string; name: string; issuePrefix: string | null }
| null,
selectedCompanyId: "company-1" as string | null,
}));
const mockLocationState = vi.hoisted(() => ({
pathname: "/PAP/company/settings/cloud-upstream",
search: "",
}));
vi.mock("@/api/cloudUpstreams", () => ({
cloudUpstreamsApi: mockCloudUpstreamsApi,
}));
vi.mock("@/api/instanceSettings", () => ({
instanceSettingsApi: mockInstanceSettingsApi,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompany: mockCompanyState.selectedCompany,
selectedCompanyId: mockCompanyState.selectedCompanyId,
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children: React.ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>
{children}
</a>
),
useLocation: () => ({ pathname: mockLocationState.pathname, search: mockLocationState.search }),
}));
// 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();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("CloudUpstream", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockCompanyState.selectedCompany = { id: "company-1", name: "Paperclip", issuePrefix: "PAP" };
mockCompanyState.selectedCompanyId = "company-1";
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "";
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableCloudSync: true });
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "succeeded" })));
mockCloudUpstreamsApi.activateEntities.mockImplementation((_connectionId, _runId, input) =>
Promise.resolve(buildRun({
status: "succeeded",
report: {
activationChecklist: {
[input.entityType]: {
entityType: input.entityType,
count: input.entityType === "agents" ? 2 : 1,
status: "activated",
activatedAt: "2026-05-18T19:00:00.000Z",
},
},
},
})),
);
mockCloudUpstreamsApi.createRun.mockResolvedValue(buildRun({ status: "running" }));
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("binds the succeeded run activation checklist to imported category counts", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Re-run");
expect(container.textContent).not.toContain("Retry");
expect(container.textContent).toContain("Activation checklist");
expect(container.textContent).toContain("2 paused");
expect(container.textContent).toContain("1 paused");
expect(container.textContent).toContain("0 imported monitors in this run.");
expect(container.textContent).toContain("Keep paused");
const activateButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Activate") as HTMLButtonElement | undefined;
expect(activateButton).toBeTruthy();
await act(async () => {
activateButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCloudUpstreamsApi.activateEntities).toHaveBeenCalledWith(
"connection-1",
"run-1",
{ companyId: "company-1", entityType: "agents" },
);
await act(async () => {
root.unmount();
});
});
it("sends a company-prefixed redirectUri when starting Connect", async () => {
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.startConnect.mockResolvedValue({
pendingConnectionId: "pending-1",
authorizationUrl: "https://cloud.example/upstream-consent?state=abc",
});
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
const input = container.querySelector<HTMLInputElement>("input[aria-label='Paperclip Cloud stack URL']");
expect(input).toBeTruthy();
await act(async () => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!;
setter.call(input!, "https://cloud.example/PAP/dashboard");
input!.dispatchEvent(new Event("input", { bubbles: true }));
});
await flushReact();
const connectButton = Array.from(container.querySelectorAll("button"))
.find((button) => button.textContent?.trim() === "Connect") as HTMLButtonElement | undefined;
expect(connectButton).toBeTruthy();
await act(async () => {
connectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
expect(mockCloudUpstreamsApi.startConnect).toHaveBeenCalledWith({
companyId: "company-1",
remoteUrl: "https://cloud.example/PAP/dashboard",
redirectUri: `${window.location.origin}/PAP/company/settings/cloud-upstream`,
});
await act(async () => {
root.unmount();
});
});
it("uses the URL pathname prefix when cleaning up the callback URL with no company context", async () => {
mockCompanyState.selectedCompany = null;
mockCompanyState.selectedCompanyId = null;
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "?code=cb-code&state=cb-state";
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.finishConnect.mockResolvedValue({
id: "connection-1",
companyId: "company-1",
remoteUrl: "https://cloud.example/PAP",
target: {
stackId: "stack-1",
stackSlug: "stack",
stackDisplayName: "Paperclip Cloud",
companyId: "cloud-company-1",
primaryHost: "cloud.example",
origin: "https://cloud.example",
product: "Paperclip Cloud",
schemaMajor: 1,
maxChunkBytes: 1024,
},
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
expiresAt: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:00:00.000Z",
lastRunId: null,
});
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
const replaceStateSpy = vi.spyOn(window.history, "replaceState");
try {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledWith({
pendingConnectionId: "pending-1",
code: "cb-code",
state: "cb-state",
});
expect(replaceStateSpy).toHaveBeenCalledWith(null, "", "/PAP/company/settings/cloud-upstream");
await act(async () => {
root.unmount();
});
} finally {
replaceStateSpy.mockRestore();
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
}
});
it("does not retry the OAuth callback finish mutation after an error", async () => {
mockLocationState.pathname = "/PAP/company/settings/cloud-upstream";
mockLocationState.search = "?code=cb-code&state=cb-state";
mockCloudUpstreamsApi.list.mockResolvedValue({ connections: [], runs: [] });
mockCloudUpstreamsApi.finishConnect.mockRejectedValue(new Error("state expired"));
window.localStorage.setItem("paperclip-cloud-upstream-pending-connection", "pending-1");
try {
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
await flushReact();
expect(mockCloudUpstreamsApi.finishConnect).toHaveBeenCalledTimes(1);
expect(container.textContent).toContain("state expired");
await act(async () => {
root.unmount();
});
} finally {
window.localStorage.removeItem("paperclip-cloud-upstream-pending-connection");
}
});
it("keeps retry only for failed or cancelled runs", async () => {
mockCloudUpstreamsApi.list.mockResolvedValue(stateWithRun(buildRun({ status: "failed" })));
const root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CloudUpstream />
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
expect(container.textContent).toContain("Retry");
expect(container.textContent).not.toContain("Re-run");
expect(container.textContent).not.toContain("Activation checklist");
await act(async () => {
root.unmount();
});
});
});
describe("buildActivationRows", () => {
it("reads activation decisions from the run report", () => {
const rows = buildActivationRows(buildRun({
status: "succeeded",
report: {
activationChecklist: {
agents: {
entityType: "agents",
count: 2,
status: "activated",
activatedAt: "2026-05-18T19:00:00.000Z",
},
},
},
}));
expect(rows[0]).toMatchObject({ key: "agents", count: 2, status: "activated", statusLabel: "2 activated" });
expect(rows[2]).toMatchObject({ key: "monitors", count: 0, status: "paused", statusLabel: "0 imported" });
});
});
function stateWithRun(run: CloudUpstreamRun): CloudUpstreamsState {
return {
connections: [
{
id: "connection-1",
companyId: "company-1",
remoteUrl: "https://paperclip.example/PAP",
target: {
stackId: "stack-1",
stackSlug: "stack",
stackDisplayName: "Paperclip Cloud",
companyId: "cloud-company-1",
primaryHost: "paperclip.example",
origin: "https://paperclip.example",
product: "Paperclip Cloud",
schemaMajor: 1,
maxChunkBytes: 1024,
},
tokenStatus: "connected",
scopes: ["upstream_import:write"],
authorizedGlobalUserId: "user-1",
expiresAt: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:00:00.000Z",
lastRunId: run.id,
},
],
runs: [run],
};
}
function buildRun(input: {
status: CloudUpstreamRun["status"];
report?: Record<string, unknown>;
}): CloudUpstreamRun {
return {
id: "run-1",
connectionId: "connection-1",
companyId: "company-1",
status: input.status,
activeStep: input.status === "succeeded" ? "activate" : "push",
progressPercent: input.status === "running" ? 70 : 100,
dryRun: false,
summary: [
{ key: "agents", label: "Agents", count: 2 },
{ key: "routines", label: "Routines", count: 1 },
{ key: "issues", label: "Issues", count: 7 },
],
warnings: [],
conflicts: [],
events: [
{
id: "event-1",
at: "2026-05-18T18:30:00.000Z",
phase: input.status === "succeeded" ? "activate" : "push",
type: input.status === "failed" ? "failed" : "completed",
message: input.status === "failed" ? "Push failed." : "Activation checklist is ready.",
},
],
targetUrl: "https://paperclip.example",
report: input.report ?? {},
retryOfRunId: null,
createdAt: "2026-05-18T18:00:00.000Z",
updatedAt: "2026-05-18T18:30:00.000Z",
completedAt: input.status === "running" ? null : "2026-05-18T18:30:00.000Z",
};
}
+646
View File
@@ -0,0 +1,646 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
CloudUpload,
ExternalLink,
FileJson,
History,
Loader2,
RefreshCcw,
ShieldAlert,
} from "lucide-react";
import type {
CloudUpstreamActivationDecision,
CloudUpstreamActivationEntityType,
CloudUpstreamPreview,
CloudUpstreamRun,
CloudUpstreamStep,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { cloudUpstreamsApi } from "@/api/cloudUpstreams";
import { instanceSettingsApi } from "@/api/instanceSettings";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { applyCompanyPrefix, extractCompanyPrefixFromPath } from "@/lib/company-routes";
import { Link, useLocation } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
const PENDING_CONNECTION_KEY = "paperclip-cloud-upstream-pending-connection";
const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
{ key: "connect", label: "Connect" },
{ key: "scan", label: "Scan" },
{ key: "preview", label: "Preview" },
{ key: "push", label: "Push" },
{ key: "verify", label: "Verify" },
{ key: "activate", label: "Activate" },
];
const ACTIVATION_CATEGORIES: Array<{
key: CloudUpstreamActivationEntityType;
label: string;
singular: string;
detail: string;
}> = [
{
key: "agents",
label: "Agents",
singular: "agent",
detail: "Confirm cloud secrets and adapter credentials before unpausing imported agents.",
},
{
key: "routines",
label: "Routines",
singular: "routine",
detail: "Review schedules and trigger settings before enabling imported routines.",
},
{
key: "monitors",
label: "Monitors",
singular: "monitor",
detail: "Activate after the target stack has been smoke tested.",
},
];
export function CloudUpstream() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const location = useLocation();
const [remoteUrl, setRemoteUrl] = useState("");
const [preview, setPreview] = useState<CloudUpstreamPreview | null>(null);
const [activeRun, setActiveRun] = useState<CloudUpstreamRun | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Cloud upstream" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
const experimentalQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
const cloudSyncEnabled = experimentalQuery.data?.enableCloudSync === true;
const upstreamQuery = useQuery({
queryKey: selectedCompanyId ? queryKeys.cloudUpstreams(selectedCompanyId) : ["cloud-upstreams", "__disabled__"],
queryFn: () => cloudUpstreamsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId && cloudSyncEnabled,
});
const connection = upstreamQuery.data?.connections[0] ?? null;
const latestRun = activeRun ?? upstreamQuery.data?.runs[0] ?? null;
const callbackParams = useMemo(() => new URLSearchParams(location.search), [location.search]);
const code = callbackParams.get("code");
const state = callbackParams.get("state");
const callbackError = callbackParams.get("error");
const settingsPath = useMemo(() => {
const pathPrefix = extractCompanyPrefixFromPath(location.pathname);
return applyCompanyPrefix("/company/settings/cloud-upstream", pathPrefix ?? selectedCompany?.issuePrefix ?? null);
}, [location.pathname, selectedCompany?.issuePrefix]);
const finishMutation = useMutation({
mutationFn: (input: { pendingConnectionId: string; code: string; state: string }) =>
cloudUpstreamsApi.finishConnect(input),
onSuccess: async () => {
localStorage.removeItem(PENDING_CONNECTION_KEY);
setNotice("Cloud upstream connection approved.");
setActionError(null);
await invalidateUpstreams();
window.history.replaceState(null, "", settingsPath);
},
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to finish connection."),
});
const {
mutate: finishConnect,
isError: finishConnectFailed,
isPending: finishConnectPending,
isSuccess: finishConnectSucceeded,
} = finishMutation;
useEffect(() => {
if (!cloudSyncEnabled || !code || !state || finishConnectPending || finishConnectSucceeded || finishConnectFailed) return;
const pendingConnectionId = localStorage.getItem(PENDING_CONNECTION_KEY);
if (!pendingConnectionId) {
setActionError("No pending cloud upstream connection was found. Start the connection again.");
return;
}
finishConnect({ pendingConnectionId, code, state });
}, [cloudSyncEnabled, code, finishConnect, finishConnectFailed, finishConnectPending, finishConnectSucceeded, state]);
useEffect(() => {
if (callbackError) {
setActionError(`Cloud upstream connection was not approved: ${callbackError}`);
}
}, [callbackError]);
const startMutation = useMutation({
mutationFn: () =>
cloudUpstreamsApi.startConnect({
companyId: selectedCompanyId!,
remoteUrl,
redirectUri: `${window.location.origin}${settingsPath}`,
}),
onSuccess: (result) => {
localStorage.setItem(PENDING_CONNECTION_KEY, result.pendingConnectionId);
setActionError(null);
window.location.assign(result.authorizationUrl);
},
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to start connection."),
});
const previewMutation = useMutation({
mutationFn: (input: { connectionId: string; companyId: string }) =>
cloudUpstreamsApi.preview(input.connectionId, { companyId: input.companyId }),
onSuccess: (nextPreview) => {
setPreview(nextPreview);
setActionError(null);
},
onError: (error) => setActionError(previewErrorMessage(error)),
});
const runMutation = useMutation({
mutationFn: (input: { connectionId: string; companyId: string; retryOfRunId?: string | null }) =>
cloudUpstreamsApi.createRun(input.connectionId, {
companyId: input.companyId,
retryOfRunId: input.retryOfRunId ?? null,
}),
onSuccess: async (run) => {
setActiveRun(run);
setNotice(run.status === "succeeded"
? "Push run completed. Review activation before unpausing automations."
: "Push run failed. Review the run events and retry after correcting the issue.");
setActionError(null);
await invalidateUpstreams();
},
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to run push."),
});
const activationMutation = useMutation({
mutationFn: (input: { run: CloudUpstreamRun; entityType: CloudUpstreamActivationEntityType }) =>
cloudUpstreamsApi.activateEntities(input.run.connectionId, input.run.id, {
companyId: input.run.companyId,
entityType: input.entityType,
}),
onSuccess: async (run) => {
setActiveRun(run);
setNotice("Activation checklist updated.");
setActionError(null);
await invalidateUpstreams();
},
onError: (error) => setActionError(error instanceof Error ? error.message : "Failed to activate imported entities."),
});
async function invalidateUpstreams() {
if (!selectedCompanyId) return;
await queryClient.invalidateQueries({ queryKey: queryKeys.cloudUpstreams(selectedCompanyId) });
}
if (!selectedCompanyId || !selectedCompany) {
return <div className="text-sm text-muted-foreground">Select a company to configure cloud upstream.</div>;
}
if (experimentalQuery.isLoading) {
return <div className="text-sm text-muted-foreground">Loading experimental settings...</div>;
}
if (!cloudSyncEnabled) {
return (
<div className="max-w-2xl space-y-4">
<div className="flex items-center gap-2">
<CloudUpload className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Cloud upstream</h1>
</div>
<div className="rounded-md border border-border px-4 py-4 text-sm text-muted-foreground">
Cloud sync is disabled. Enable it in{" "}
<Link className="text-primary underline-offset-2 hover:underline" to="/instance/settings/experimental">
Instance Settings
</Link>{" "}
to show upstream connection and push tools.
</div>
</div>
);
}
return (
<div className="max-w-6xl space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<CloudUpload className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Cloud upstream</h1>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Push {selectedCompany.name} into a Paperclip Cloud stack. Automations stay paused until activation.
</p>
</div>
{connection?.target.origin ? (
<Button variant="outline" size="sm" asChild>
<a href={connection.target.origin} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open cloud
</a>
</Button>
) : null}
</div>
{notice ? (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{notice}
</div>
) : null}
{actionError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
<Stepper activeStep={latestRun?.activeStep ?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect")} />
<section className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Connection</div>
<div className="rounded-md border border-border px-4 py-4">
{connection ? (
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-start">
<div>
<div className="text-sm font-medium">
{connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
</div>
<div className="mt-2 text-xs text-muted-foreground">
Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
</div>
</div>
<div className="flex flex-col items-end gap-1">
<Button
variant="outline"
size="sm"
onClick={() => previewMutation.mutate({ connectionId: connection.id, companyId: connection.companyId })}
disabled={previewMutation.isPending || connection.tokenStatus !== "connected"}
>
{previewMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
Preview push
</Button>
{previewMutation.isPending ? <PreviewProgressHint /> : null}
</div>
</div>
) : (
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<Input
value={remoteUrl}
onChange={(event) => setRemoteUrl(event.target.value)}
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
aria-label="Paperclip Cloud stack URL"
/>
<Button onClick={() => startMutation.mutate()} disabled={startMutation.isPending || !remoteUrl.trim()}>
{startMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
Connect
</Button>
</div>
)}
</div>
</section>
{preview ? (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
<Button
onClick={() => runMutation.mutate({ connectionId: preview.connectionId, companyId: preview.sourceCompanyId })}
disabled={runMutation.isPending || !preview.schemaCompatible}
>
{runMutation.isPending ? <Loader2 className="h-4 w-4 animate-spin" /> : <CloudUpload className="h-4 w-4" />}
Push to cloud
</Button>
</div>
<SummaryGrid summary={preview.summary} />
<WarningsPanel warnings={preview.warnings} />
<ConflictTable conflicts={preview.conflicts} />
</section>
) : null}
{latestRun ? (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Progress and finish</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm" onClick={() => downloadRunReport(latestRun)}>
<FileJson className="h-4 w-4" />
Download report
</Button>
{latestRun.status === "failed" || latestRun.status === "cancelled" ? (
<Button
variant="outline"
size="sm"
onClick={() => runMutation.mutate({
connectionId: latestRun.connectionId,
companyId: latestRun.companyId,
retryOfRunId: latestRun.id,
})}
disabled={runMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
Retry
</Button>
) : latestRun.status === "succeeded" ? (
<Button
variant="outline"
size="sm"
onClick={() => runMutation.mutate({ connectionId: latestRun.connectionId, companyId: latestRun.companyId })}
disabled={runMutation.isPending}
>
<RefreshCcw className="h-4 w-4" />
Re-run
</Button>
) : null}
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium capitalize">{latestRun.status}</div>
<div className="mt-1 text-xs text-muted-foreground">
Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt ? `completed ${formatDate(latestRun.completedAt)}` : "in progress"}
</div>
</div>
<div className="text-sm tabular-nums">{latestRun.progressPercent}%</div>
</div>
<div className="mt-3 h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${latestRun.progressPercent}%` }} />
</div>
<div className="mt-4 divide-y divide-border">
{latestRun.events.map((event) => (
<div key={event.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[7rem_8rem_1fr]">
<span className="text-xs text-muted-foreground">{formatDate(event.at)}</span>
<span className="text-xs capitalize text-muted-foreground">{event.phase}</span>
<span>{event.message}</span>
</div>
))}
</div>
</div>
{latestRun.status === "succeeded" ? (
<ActivationChecklist
run={latestRun}
pendingEntityType={activationMutation.variables?.entityType ?? null}
isPending={activationMutation.isPending}
onActivate={(entityType) => activationMutation.mutate({ run: latestRun, entityType })}
/>
) : null}
</section>
) : null}
{upstreamQuery.data?.runs.length ? (
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<History className="h-3.5 w-3.5" />
History
</div>
<div className="divide-y divide-border rounded-md border border-border">
{upstreamQuery.data.runs.map((run) => (
<button
key={run.id}
type="button"
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
onClick={() => setActiveRun(run)}
>
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
</button>
))}
</div>
</section>
) : null}
</div>
);
}
function PreviewProgressHint() {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const interval = window.setInterval(() => setElapsed(Math.round((Date.now() - startedAt) / 1000)), 1000);
return () => window.clearInterval(interval);
}, []);
const message = elapsed < 15
? "Building manifest..."
: elapsed < 45
? `Building manifest... ${elapsed}s. Large companies can take up to a minute.`
: `Still building manifest... ${elapsed}s. PAP-scale companies routinely take ~60s.`;
return <div className="text-xs text-muted-foreground">{message}</div>;
}
function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
return (
<div className="grid gap-2 rounded-md border border-border px-3 py-3 sm:grid-cols-6">
{STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div key={step.key} className="flex items-center gap-2 text-xs">
{complete ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<span className={active ? "h-4 w-4 rounded-full border-2 border-primary" : "h-4 w-4 rounded-full border border-border"} />
)}
<span className={active ? "font-medium text-foreground" : "text-muted-foreground"}>{step.label}</span>
</div>
);
})}
</div>
);
}
function SummaryGrid({ summary }: { summary: CloudUpstreamPreview["summary"] }) {
return (
<div className="grid gap-2 sm:grid-cols-4">
{summary.map((item) => (
<div key={item.key} className="rounded-md border border-border px-3 py-2">
<div className="text-lg font-semibold tabular-nums">{item.count}</div>
<div className="text-xs text-muted-foreground">{item.label}</div>
</div>
))}
</div>
);
}
function WarningsPanel({ warnings }: { warnings: CloudUpstreamPreview["warnings"] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
Warnings
</div>
<div className="divide-y divide-border">
{warnings.map((warning) => (
<div key={warning.code} className="grid gap-2 py-2 sm:grid-cols-[1.25rem_12rem_1fr]">
<AlertTriangle className={warning.severity === "blocker" ? "h-4 w-4 text-destructive" : "h-4 w-4 text-amber-600"} />
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-sm text-muted-foreground">{warning.detail}</div>
</div>
))}
</div>
</div>
);
}
function ConflictTable({ conflicts }: { conflicts: CloudUpstreamPreview["conflicts"] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Conflicts</div>
{conflicts.length === 0 ? (
<div className="text-sm text-muted-foreground">No target conflicts detected for this preview.</div>
) : (
<div className="divide-y divide-border">
{conflicts.map((conflict) => (
<div key={conflict.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_1fr_8rem]">
<span className="text-muted-foreground">{conflict.entityType}</span>
<span>{conflict.sourceLabel}</span>
<span>{conflict.targetLabel}</span>
<span className="capitalize">{conflict.plannedAction}</span>
</div>
))}
</div>
)}
</div>
);
}
function ActivationChecklist({
run,
pendingEntityType,
isPending,
onActivate,
}: {
run: CloudUpstreamRun;
pendingEntityType: CloudUpstreamActivationEntityType | null;
isPending: boolean;
onActivate: (entityType: CloudUpstreamActivationEntityType) => void;
}) {
const rows = buildActivationRows(run);
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Activation checklist</div>
<div className="divide-y divide-border">
{rows.map((row) => {
const pending = isPending && pendingEntityType === row.key;
const activated = row.status === "activated";
return (
<div key={row.key} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto] sm:items-center">
<div>
<div className="font-medium">{row.label}</div>
<div className="text-xs text-muted-foreground">{row.statusLabel}</div>
</div>
<div className="text-muted-foreground">
{row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button
variant={activated ? "secondary" : "default"}
size="sm"
onClick={() => onActivate(row.key)}
disabled={row.count === 0 || activated || isPending}
>
{pending ? <Loader2 className="h-4 w-4 animate-spin" /> : null}
{activated ? "Activated" : "Activate"}
</Button>
<Button variant="ghost" size="sm" disabled={activated || isPending}>
Keep paused
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
export function buildActivationRows(run: CloudUpstreamRun) {
const activationChecklist = activationChecklistFromReport(run.report);
return ACTIVATION_CATEGORIES.map((category) => {
const decision = activationChecklist[category.key];
const count = summaryCount(run.summary, category.key);
const status = decision?.status === "activated" ? "activated" : "paused";
const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
return {
...category,
count,
pluralLabel,
status,
detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
statusLabel: status === "activated"
? `${count} activated`
: count === 0
? "0 imported"
: `${count} paused`,
};
});
}
function summaryCount(summary: CloudUpstreamRun["summary"], key: CloudUpstreamActivationEntityType): number {
return summary.find((item) => item.key === key)?.count ?? 0;
}
function activationChecklistFromReport(report: CloudUpstreamRun["report"]): Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> {
const value = optionalRecord(report.activationChecklist);
const decisions: Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> = {};
for (const key of ["agents", "routines", "monitors"] as const) {
const item = optionalRecord(value[key]);
if (!item) continue;
decisions[key] = {
entityType: key,
count: typeof item.count === "number" ? item.count : 0,
status: item.status === "activated" ? "activated" : "paused",
activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
};
}
return decisions;
}
function optionalRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function downloadRunReport(run: CloudUpstreamRun) {
const blob = new Blob([JSON.stringify(run.report, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `cloud-upstream-run-${run.id}.json`;
anchor.click();
URL.revokeObjectURL(url);
}
function formatDate(value: string) {
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
return `${value} B`;
}
function previewErrorMessage(error: unknown): string {
const code = error instanceof Error ? error.message : null;
if (code === "payload_too_large" || code === "bad_request") {
return "Local company is too large to preview as a single request. Click Push to continue (the Push step uploads in chunks), or see the docs for chunked-preview options.";
}
return code ?? "Failed to preview push.";
}
+822
View File
@@ -0,0 +1,822 @@
import { useMemo } from "react";
import {
AlertTriangle,
CheckCircle2,
CloudUpload,
ExternalLink,
FileJson,
History,
Loader2,
RefreshCcw,
ShieldAlert,
} from "lucide-react";
import type {
CloudUpstreamActivationDecision,
CloudUpstreamActivationEntityType,
CloudUpstreamConflict,
CloudUpstreamConnection,
CloudUpstreamPreview,
CloudUpstreamRun,
CloudUpstreamStep,
CloudUpstreamSummaryCount,
CloudUpstreamWarning,
} from "@paperclipai/shared";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useLocation } from "@/lib/router";
type FixtureStateKey =
| "settings-pane"
| "connect-wizard"
| "schema-mismatch"
| "preview"
| "preview-clean"
| "progress"
| "retry"
| "finish";
const STEPS: Array<{ key: CloudUpstreamStep; label: string }> = [
{ key: "connect", label: "Connect" },
{ key: "scan", label: "Scan" },
{ key: "preview", label: "Preview" },
{ key: "push", label: "Push" },
{ key: "verify", label: "Verify" },
{ key: "activate", label: "Activate" },
];
const ACTIVATION_CATEGORIES: Array<{
key: CloudUpstreamActivationEntityType;
label: string;
singular: string;
detail: string;
}> = [
{
key: "agents",
label: "Agents",
singular: "agent",
detail: "Keep paused until cloud secrets and adapter credentials are verified.",
},
{
key: "routines",
label: "Routines",
singular: "routine",
detail: "Review schedules before enabling triggers.",
},
{
key: "monitors",
label: "Monitors",
singular: "monitor",
detail: "Activate after the target instance has been smoke tested.",
},
];
const FIXTURE_LABELS: Record<FixtureStateKey, string> = {
"settings-pane": "1 · Settings → Cloud upstream pane (enabled)",
"connect-wizard": "2 · Connect wizard — remote URL entry + PKCE launch",
"schema-mismatch": "3 · Connect wizard — schema-mismatch hard block",
preview: "4 · Preview — conflicts, warnings, planned actions",
"preview-clean": "5 · Preview — clean run with no conflicts",
progress: "6 · Durable progress — mid-run from run events",
retry: "7 · Retry without duplicating ledger entries",
finish: "8 · Finish / activation checklist with run report",
};
const PARSE_ORDER: FixtureStateKey[] = [
"settings-pane",
"connect-wizard",
"schema-mismatch",
"preview",
"preview-clean",
"progress",
"retry",
"finish",
];
export function CloudUpstreamUxLab() {
const location = useLocation();
const { state, showChrome } = useMemo(() => {
const params = new URLSearchParams(location.search);
const raw = (params.get("state") ?? "settings-pane") as FixtureStateKey;
return {
state: PARSE_ORDER.includes(raw) ? raw : "settings-pane",
showChrome: params.get("chrome") === "on",
};
}, [location.search]);
const fixture = useMemo(() => buildFixture(state), [state]);
return (
<div className="mx-auto max-w-6xl space-y-6 p-6">
{showChrome ? <FixtureNav active={state} /> : null}
<CloudUpstreamRender fixture={fixture} />
</div>
);
}
function FixtureNav({ active }: { active: FixtureStateKey }) {
return (
<div className="rounded-md border border-dashed border-border/70 bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<div className="mb-1 font-semibold uppercase tracking-wide">UX lab · cloud upstream</div>
<div className="flex flex-wrap gap-x-3 gap-y-1">
{PARSE_ORDER.map((key) => (
<a
key={key}
href={`?state=${key}`}
className={
active === key
? "rounded bg-primary/10 px-2 py-0.5 font-medium text-primary"
: "rounded px-2 py-0.5 hover:bg-accent/40"
}
>
{FIXTURE_LABELS[key]}
</a>
))}
</div>
</div>
);
}
interface Fixture {
selectedCompanyName: string;
connection: CloudUpstreamConnection | null;
preview: CloudUpstreamPreview | null;
latestRun: CloudUpstreamRun | null;
history: CloudUpstreamRun[];
notice: string | null;
actionError: string | null;
}
function CloudUpstreamRender({ fixture }: { fixture: Fixture }) {
const { connection, preview, latestRun, history, notice, actionError, selectedCompanyName } = fixture;
const activeStep: CloudUpstreamStep = latestRun?.activeStep
?? (preview ? "preview" : connection?.tokenStatus === "connected" ? "scan" : "connect");
return (
<div className="space-y-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<CloudUpload className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Cloud upstream</h1>
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Push {selectedCompanyName} into a Paperclip Cloud stack. Automations stay paused until activation.
</p>
</div>
{connection?.target.origin ? (
<Button variant="outline" size="sm" asChild>
<a href={connection.target.origin} target="_blank" rel="noreferrer">
<ExternalLink className="h-4 w-4" />
Open cloud
</a>
</Button>
) : null}
</div>
{notice ? (
<div className="rounded-md border border-emerald-500/30 bg-emerald-500/5 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300">
{notice}
</div>
) : null}
{actionError ? (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
{actionError}
</div>
) : null}
<Stepper activeStep={activeStep} />
<section className="space-y-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Connection</div>
<div className="rounded-md border border-border px-4 py-4">
{connection ? (
<div className="grid gap-3 lg:grid-cols-[1fr_auto] lg:items-start">
<div>
<div className="text-sm font-medium">
{connection.target.stackDisplayName ?? connection.target.stackSlug ?? connection.target.stackId}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{connection.target.product} · {connection.target.origin} · token {connection.tokenStatus}
</div>
<div className="mt-2 text-xs text-muted-foreground">
Schema {connection.target.schemaMajor}. Max chunk {formatBytes(connection.target.maxChunkBytes)}.
</div>
</div>
<Button variant="outline" size="sm">
<RefreshCcw className="h-4 w-4" />
Preview push
</Button>
</div>
) : (
<div className="grid gap-3 md:grid-cols-[1fr_auto]">
<Input
defaultValue="https://paperclip.paperclip.app/PC521D/dashboard"
placeholder="https://paperclip.paperclip.app/PC521D/dashboard"
aria-label="Paperclip Cloud stack URL"
autoFocus
/>
<Button disabled>
<Loader2 className="h-4 w-4 animate-spin" />
Discovering
</Button>
</div>
)}
</div>
</section>
{preview ? (
<section className="space-y-3">
<div className="flex items-center justify-between gap-3">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Preview</div>
<Button disabled={!preview.schemaCompatible}>
<CloudUpload className="h-4 w-4" />
Push to cloud
</Button>
</div>
<SummaryGrid summary={preview.summary} />
<WarningsPanel warnings={preview.warnings} />
<ConflictTable conflicts={preview.conflicts} />
</section>
) : null}
{latestRun ? (
<section className="space-y-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Progress and finish</div>
<div className="flex flex-wrap gap-2">
<Button variant="outline" size="sm">
<FileJson className="h-4 w-4" />
Download report
</Button>
{latestRun.status === "failed" || latestRun.status === "cancelled" ? (
<Button variant="outline" size="sm">
<RefreshCcw className="h-4 w-4" />
Retry
</Button>
) : latestRun.status === "succeeded" ? (
<Button variant="outline" size="sm">
<RefreshCcw className="h-4 w-4" />
Re-run
</Button>
) : null}
</div>
</div>
<div className="rounded-md border border-border px-4 py-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-medium capitalize">{latestRun.status}</div>
<div className="mt-1 text-xs text-muted-foreground">
Run {latestRun.id.slice(0, 8)} · {latestRun.completedAt
? `completed ${formatDate(latestRun.completedAt)}`
: latestRun.status === "running"
? "in progress"
: "in progress"}
</div>
</div>
<div className="text-sm tabular-nums">{latestRun.progressPercent}%</div>
</div>
<div className="mt-3 h-2 rounded-full bg-muted">
<div className="h-2 rounded-full bg-primary" style={{ width: `${latestRun.progressPercent}%` }} />
</div>
<div className="mt-4 divide-y divide-border">
{latestRun.events.map((event) => (
<div key={event.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[7rem_8rem_1fr]">
<span className="text-xs text-muted-foreground">{formatDate(event.at)}</span>
<span className="text-xs capitalize text-muted-foreground">{event.phase}</span>
<span>{event.message}</span>
</div>
))}
</div>
</div>
{latestRun.status === "succeeded" ? <ActivationChecklist run={latestRun} /> : null}
</section>
) : null}
{history.length ? (
<section className="space-y-3">
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<History className="h-3.5 w-3.5" />
History
</div>
<div className="divide-y divide-border rounded-md border border-border">
{history.map((run) => (
<div
key={run.id}
className="grid w-full gap-1 px-4 py-3 text-left text-sm hover:bg-accent/40 sm:grid-cols-[1fr_auto]"
>
<span>Run {run.id.slice(0, 8)} · {run.status}</span>
<span className="text-xs text-muted-foreground">{formatDate(run.createdAt)}</span>
</div>
))}
</div>
</section>
) : null}
</div>
);
}
function Stepper({ activeStep }: { activeStep: CloudUpstreamStep }) {
const activeIndex = STEPS.findIndex((step) => step.key === activeStep);
return (
<div className="grid gap-2 rounded-md border border-border px-3 py-3 sm:grid-cols-6">
{STEPS.map((step, index) => {
const complete = index < activeIndex;
const active = index === activeIndex;
return (
<div key={step.key} className="flex items-center gap-2 text-xs">
{complete ? (
<CheckCircle2 className="h-4 w-4 text-emerald-600" />
) : (
<span className={active ? "h-4 w-4 rounded-full border-2 border-primary" : "h-4 w-4 rounded-full border border-border"} />
)}
<span className={active ? "font-medium text-foreground" : "text-muted-foreground"}>{step.label}</span>
</div>
);
})}
</div>
);
}
function SummaryGrid({ summary }: { summary: CloudUpstreamSummaryCount[] }) {
return (
<div className="grid gap-2 sm:grid-cols-4">
{summary.map((item) => (
<div key={item.key} className="rounded-md border border-border px-3 py-2">
<div className="text-lg font-semibold tabular-nums">{item.count}</div>
<div className="text-xs text-muted-foreground">{item.label}</div>
</div>
))}
</div>
);
}
function WarningsPanel({ warnings }: { warnings: CloudUpstreamWarning[] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 flex items-center gap-2 text-sm font-medium">
<ShieldAlert className="h-4 w-4 text-muted-foreground" />
Warnings
</div>
<div className="divide-y divide-border">
{warnings.map((warning) => (
<div key={warning.code} className="grid gap-2 py-2 sm:grid-cols-[1.25rem_12rem_1fr]">
<AlertTriangle className={warning.severity === "blocker" ? "h-4 w-4 text-destructive" : "h-4 w-4 text-amber-600"} />
<div className="text-sm font-medium">{warning.title}</div>
<div className="text-sm text-muted-foreground">{warning.detail}</div>
</div>
))}
</div>
</div>
);
}
function ConflictTable({ conflicts }: { conflicts: CloudUpstreamConflict[] }) {
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Conflicts</div>
{conflicts.length === 0 ? (
<div className="text-sm text-muted-foreground">No target conflicts detected for this preview.</div>
) : (
<div className="divide-y divide-border">
{conflicts.map((conflict) => (
<div key={conflict.id} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_1fr_8rem]">
<span className="text-muted-foreground">{conflict.entityType}</span>
<span>{conflict.sourceLabel}</span>
<span>{conflict.targetLabel}</span>
<span className="capitalize">{conflict.plannedAction}</span>
</div>
))}
</div>
)}
</div>
);
}
function ActivationChecklist({ run }: { run: CloudUpstreamRun }) {
const rows = buildActivationRows(run);
return (
<div className="rounded-md border border-border px-4 py-3">
<div className="mb-2 text-sm font-medium">Activation checklist</div>
<div className="divide-y divide-border">
{rows.map((row) => {
const activated = row.status === "activated";
return (
<div key={row.key} className="grid gap-2 py-2 text-sm sm:grid-cols-[8rem_1fr_auto] sm:items-center">
<div>
<div className="font-medium">{row.label}</div>
<div className="text-xs text-muted-foreground">{row.statusLabel}</div>
</div>
<div className="text-muted-foreground">
{row.count === 0 ? `0 imported ${row.pluralLabel} in this run.` : row.detail}
</div>
<div className="flex flex-wrap gap-2 sm:justify-end">
<Button variant={activated ? "secondary" : "default"} size="sm" disabled={row.count === 0 || activated}>
{activated ? "Activated" : "Activate"}
</Button>
<Button variant="ghost" size="sm" disabled={activated}>
Keep paused
</Button>
</div>
</div>
);
})}
</div>
</div>
);
}
function buildActivationRows(run: CloudUpstreamRun) {
const decisions = decisionsFromReport(run.report);
return ACTIVATION_CATEGORIES.map((category) => {
const decision = decisions[category.key];
const count = summaryCount(run.summary, category.key);
const status = decision?.status === "activated" ? "activated" : "paused";
const pluralLabel = `${category.singular}${count === 1 ? "" : "s"}`;
return {
...category,
count,
pluralLabel,
status,
detail: `${count} imported ${pluralLabel} are paused by default. ${category.detail}`,
statusLabel: status === "activated"
? `${count} activated`
: count === 0
? "0 imported"
: `${count} paused`,
};
});
}
function decisionsFromReport(report: Record<string, unknown>): Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> {
const value = optionalRecord(report.activationChecklist);
const decisions: Partial<Record<CloudUpstreamActivationEntityType, CloudUpstreamActivationDecision>> = {};
for (const key of ["agents", "routines", "monitors"] as const) {
const item = optionalRecord(value[key]);
if (!item) continue;
decisions[key] = {
entityType: key,
count: typeof item.count === "number" ? item.count : 0,
status: item.status === "activated" ? "activated" : "paused",
activatedAt: typeof item.activatedAt === "string" ? item.activatedAt : null,
};
}
return decisions;
}
function summaryCount(summary: CloudUpstreamSummaryCount[], key: CloudUpstreamActivationEntityType): number {
return summary.find((item) => item.key === key)?.count ?? 0;
}
function optionalRecord(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function formatDate(value: string) {
return new Date(value).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
});
}
function formatBytes(value: number) {
if (value >= 1024 * 1024) return `${Math.round(value / (1024 * 1024))} MiB`;
if (value >= 1024) return `${Math.round(value / 1024)} KiB`;
return `${value} B`;
}
const STACK_TARGET = {
stackId: "stk_2vKqz9D8mNFqQ7Rp",
stackSlug: "paperclip-prod",
stackDisplayName: "Paperclip Prod",
companyId: "co_4hT2yX",
primaryHost: "paperclip.paperclip.app",
origin: "https://paperclip.paperclip.app",
product: "paperclip-cloud",
schemaMajor: 7,
maxChunkBytes: 5 * 1024 * 1024,
};
const STACK_TARGET_SCHEMA_BEHIND = {
...STACK_TARGET,
schemaMajor: 5,
};
function connectedConnection(target = STACK_TARGET): CloudUpstreamConnection {
return {
id: "cu_conn_8d3f1b6a",
companyId: "co_4hT2yX",
remoteUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
target,
tokenStatus: "connected",
scopes: ["upstream.push", "upstream.preview"],
authorizedGlobalUserId: "user_9pXqYzAbCdEf",
expiresAt: "2026-08-18T19:00:00.000Z",
createdAt: "2026-05-18T18:45:00.000Z",
updatedAt: "2026-05-18T19:02:18.000Z",
lastRunId: null,
};
}
const PREVIEW_SUMMARY: CloudUpstreamSummaryCount[] = [
{ key: "users", label: "Users", count: 14 },
{ key: "agents", label: "Agents", count: 6 },
{ key: "routines", label: "Routines", count: 4 },
{ key: "monitors", label: "Monitors", count: 2 },
];
const PREVIEW_WARNINGS_NORMAL: CloudUpstreamWarning[] = [
{
code: "imported_automations_paused",
severity: "warning",
title: "Automations stay paused",
detail: "Imported agents, routines, and monitors require explicit activation after the push.",
},
{
code: "unmatched_users_import_as_historical_authors",
severity: "warning",
title: "Unmatched users become historical authors",
detail: "Invite now remains a secondary action after the transfer is complete.",
},
{
code: "secret_values_redacted",
severity: "warning",
title: "Secret values are not transferred",
detail: "The push carries secret requirements only. Configure cloud secrets before activating automations.",
},
];
const PREVIEW_WARNINGS_SCHEMA: CloudUpstreamWarning[] = [
{
code: "schema_mismatch",
severity: "blocker",
title: "Cloud stack upgrade required",
detail: "This local build uses upstream schema 7, but the cloud stack reports schema 5.",
},
...PREVIEW_WARNINGS_NORMAL,
];
const PREVIEW_CONFLICTS: CloudUpstreamConflict[] = [
{
id: "conflict_user_serena",
entityType: "user",
sourceLabel: "serena@magicmachine.co (unmatched)",
targetLabel: "→ historical author Serena R.",
plannedAction: "create",
reason: "Target stack has no matching identity. Will arrive as historical author; invite available after push.",
},
{
id: "conflict_user_dotta",
entityType: "user",
sourceLabel: "dotta@magicmachine.co",
targetLabel: "↦ dotta@magicmachine.co (cloud)",
plannedAction: "update",
reason: "Existing cloud identity matches local user; will be merged.",
},
{
id: "conflict_agent_qa",
entityType: "agent",
sourceLabel: "QA · qa-bot",
targetLabel: "↦ QA · qa-bot (cloud)",
plannedAction: "update",
reason: "Mapped to existing cloud agent. Imported run history will be appended.",
},
{
id: "conflict_routine_nightly_reports",
entityType: "routine",
sourceLabel: "Nightly status report",
targetLabel: "(new in cloud)",
plannedAction: "create",
reason: "Routine does not exist in the target stack and will be created in paused state.",
},
];
function basePreview(): CloudUpstreamPreview {
return {
connectionId: "cu_conn_8d3f1b6a",
sourceCompanyId: "co_local_pc521d",
target: STACK_TARGET,
schemaCompatible: true,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
generatedAt: "2026-05-18T19:03:14.000Z",
};
}
function schemaMismatchPreview(): CloudUpstreamPreview {
return {
...basePreview(),
target: STACK_TARGET_SCHEMA_BEHIND,
schemaCompatible: false,
summary: [],
conflicts: [],
warnings: PREVIEW_WARNINGS_SCHEMA,
};
}
function cleanPreview(): CloudUpstreamPreview {
return {
...basePreview(),
conflicts: [],
warnings: PREVIEW_WARNINGS_NORMAL.slice(0, 1),
};
}
const PROGRESS_EVENTS = [
{ id: "evt_01", at: "2026-05-18T19:10:02.000Z", phase: "scan" as CloudUpstreamStep, type: "completed" as const, message: "Scanned 14 users, 6 agents, 4 routines, 2 monitors." },
{ id: "evt_02", at: "2026-05-18T19:10:11.000Z", phase: "preview" as CloudUpstreamStep, type: "completed" as const, message: "Preview generated with 4 conflicts and 3 warnings." },
{ id: "evt_03", at: "2026-05-18T19:10:31.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "users · 8 created, 6 mapped to existing identities." },
{ id: "evt_04", at: "2026-05-18T19:10:48.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "agents · 4 created paused, 2 updated paused." },
{ id: "evt_05", at: "2026-05-18T19:10:58.000Z", phase: "push" as CloudUpstreamStep, type: "updated" as const, message: "routines · 3 created paused, 1 updated." },
{ id: "evt_06", at: "2026-05-18T19:11:09.000Z", phase: "push" as CloudUpstreamStep, type: "created" as const, message: "monitors · 2 created paused." },
{ id: "evt_07", at: "2026-05-18T19:11:18.000Z", phase: "verify" as CloudUpstreamStep, type: "updated" as const, message: "Verifying transferred ledger checksums…" },
];
function runningRun(): CloudUpstreamRun {
return {
id: "run_3kQ8mNpW9bX2zL4Y",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "running",
activeStep: "push",
progressPercent: 62,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: PROGRESS_EVENTS,
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: {},
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:18.000Z",
completedAt: null,
};
}
function failedRun(): CloudUpstreamRun {
return {
id: "run_5fXqR2bT7aD8zP1K",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "failed",
activeStep: "push",
progressPercent: 78,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: [
...PROGRESS_EVENTS,
{
id: "evt_08",
at: "2026-05-18T19:11:30.000Z",
phase: "push",
type: "failed",
message: "Apply rejected: cloud rejected chunk 4 of 6 (HTTP 502). Ledger entries from chunks 13 retained; chunk 4 not committed.",
},
],
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: { ledgerCheckpoint: "chunk-3" },
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:30.000Z",
completedAt: null,
};
}
function succeededRun(): CloudUpstreamRun {
return {
id: "run_7aBcD9eFgH2iJ3kL",
connectionId: "cu_conn_8d3f1b6a",
companyId: "co_local_pc521d",
status: "succeeded",
activeStep: "activate",
progressPercent: 100,
dryRun: false,
summary: PREVIEW_SUMMARY,
warnings: PREVIEW_WARNINGS_NORMAL,
conflicts: PREVIEW_CONFLICTS,
events: [
...PROGRESS_EVENTS,
{
id: "evt_08",
at: "2026-05-18T19:11:25.000Z",
phase: "verify",
type: "completed",
message: "Ledger checksums match. Push committed.",
},
{
id: "evt_09",
at: "2026-05-18T19:11:31.000Z",
phase: "activate",
type: "completed",
message: "Activation checklist pending operator approval — automations remain paused.",
},
],
targetUrl: "https://paperclip.paperclip.app/PC521D/dashboard",
report: {
activationChecklist: {
agents: { count: 6, status: "paused", activatedAt: null },
routines: { count: 4, status: "paused", activatedAt: null },
monitors: { count: 2, status: "paused", activatedAt: null },
},
},
retryOfRunId: null,
createdAt: "2026-05-18T19:10:01.000Z",
updatedAt: "2026-05-18T19:11:31.000Z",
completedAt: "2026-05-18T19:11:31.000Z",
};
}
function buildFixture(state: FixtureStateKey): Fixture {
switch (state) {
case "settings-pane":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: null,
history: [],
notice: "Cloud upstream connection approved.",
actionError: null,
};
case "connect-wizard":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: null,
preview: null,
latestRun: null,
history: [],
notice: null,
actionError: null,
};
case "schema-mismatch":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(STACK_TARGET_SCHEMA_BEHIND),
preview: schemaMismatchPreview(),
latestRun: null,
history: [],
notice: null,
actionError: "Cloud stack is on schema 5 but this local build pushes schema 7. Upgrade the cloud stack to continue.",
};
case "preview":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: basePreview(),
latestRun: null,
history: [],
notice: null,
actionError: null,
};
case "preview-clean":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: cleanPreview(),
latestRun: null,
history: [],
notice: "Preview completed. No target conflicts detected.",
actionError: null,
};
case "progress":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: runningRun(),
history: [],
notice: null,
actionError: null,
};
case "retry":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: failedRun(),
history: [
{ ...failedRun(), id: "run_9pYqXwVtSrQ" },
],
notice: null,
actionError: "Push run failed. Review the events. Retry resumes from ledger checkpoint chunk-3 — chunks 13 will not be re-applied.",
};
case "finish":
return {
selectedCompanyName: "Paperclip · PC521D",
connection: connectedConnection(),
preview: null,
latestRun: succeededRun(),
history: [
{ ...succeededRun(), id: "run_aZcXvBnMqWeR" },
],
notice: "Push run completed. Review activation before unpausing automations.",
actionError: null,
};
}
}
+99 -17
View File
@@ -4,23 +4,25 @@ 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";
import { CompanyAccess } from "./CompanyAccess";
import { CompanyAccess, CompanyAccessLegacyRoute } from "./CompanyAccess";
const listMembersMock = vi.hoisted(() => vi.fn());
const listJoinRequestsMock = vi.hoisted(() => vi.fn());
const updateMemberAccessMock = vi.hoisted(() => vi.fn());
const updateMemberMock = vi.hoisted(() => vi.fn());
const archiveMemberMock = vi.hoisted(() => vi.fn());
const listAgentsMock = vi.hoisted(() => vi.fn());
const listIssuesMock = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockNavigate = vi.hoisted(() => vi.fn());
vi.mock("@/api/access", () => ({
accessApi: {
listMembers: (companyId: string) => listMembersMock(companyId),
listJoinRequests: (companyId: string, status: string) => listJoinRequestsMock(companyId, status),
updateMember: vi.fn(),
updateMember: (companyId: string, memberId: string, input: unknown) =>
updateMemberMock(companyId, memberId, input),
updateMemberPermissions: vi.fn(),
updateMemberAccess: (companyId: string, memberId: string, input: unknown) =>
updateMemberAccessMock(companyId, memberId, input),
updateMemberAccess: vi.fn(),
archiveMember: (companyId: string, memberId: string, input: unknown) =>
archiveMemberMock(companyId, memberId, input),
approveJoinRequest: vi.fn(),
@@ -40,6 +42,18 @@ vi.mock("@/api/issues", () => ({
},
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
Navigate: ({ to, replace }: { to: string; replace?: boolean }) => {
mockNavigate(to, replace);
return <div data-testid="navigate">{to}</div>;
},
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
selectedCompanyId: "company-1",
@@ -146,7 +160,7 @@ describe("CompanyAccess", () => {
},
},
]);
updateMemberAccessMock.mockResolvedValue({});
updateMemberMock.mockResolvedValue({});
archiveMemberMock.mockResolvedValue({ reassignedIssueCount: 1 });
listAgentsMock.mockResolvedValue([
{
@@ -164,6 +178,11 @@ describe("CompanyAccess", () => {
status: "todo",
},
]);
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
@@ -172,7 +191,7 @@ describe("CompanyAccess", () => {
vi.clearAllMocks();
});
it("keeps the page human-focused and explains implicit versus explicit grants", async () => {
it("keeps the page human-focused and hides advanced permission controls", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -188,10 +207,15 @@ describe("CompanyAccess", () => {
await flushReact();
await flushReact();
expect(container.textContent).toContain("Manage company user memberships");
expect(container.textContent).toContain("Manage the people who can work in Paperclip");
expect(container.textContent).toContain("Members can collaborate across the company by default");
expect(container.textContent).toContain("Core keeps this page focused on membership");
expect(container.textContent).toContain("Humans");
expect(container.textContent).toContain("Pending human joins");
expect(container.textContent).toContain("User account");
expect(container.textContent).not.toContain("Grants");
expect(container.textContent).not.toContain("explicit grants");
expect(container.textContent).not.toContain("Assign scoped tasks");
expect(container.textContent).not.toContain("Agents");
expect(container.textContent).not.toContain("Pending agent joins");
expect(container.textContent).not.toContain("Open join request queue");
@@ -210,18 +234,16 @@ describe("CompanyAccess", () => {
});
await flushReact();
expect(document.body.textContent).toContain("Implicit grants from role");
expect(document.body.textContent).toContain("Owner currently includes these permissions automatically.");
expect(document.body.textContent).toContain(
"Included implicitly by the Owner role. Add an explicit grant only if it should stay after the role changes.",
);
expect(document.body.textContent).toContain("Update company role and membership status");
expect(document.body.textContent).not.toContain("Implicit grants from role");
expect(document.body.textContent).not.toContain("permissionKey");
await act(async () => {
root.unmount();
});
});
it("saves member role, status, and grants in one request", async () => {
it("saves member role and status without touching grants", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
@@ -248,7 +270,7 @@ describe("CompanyAccess", () => {
await flushReact();
const saveButton = Array.from(document.body.querySelectorAll("button")).find(
(button) => button.textContent === "Save access",
(button) => button.textContent === "Save member",
);
expect(saveButton).toBeTruthy();
@@ -257,10 +279,9 @@ describe("CompanyAccess", () => {
});
await flushReact();
expect(updateMemberAccessMock).toHaveBeenCalledWith("company-1", "member-1", {
expect(updateMemberMock).toHaveBeenCalledWith("company-1", "member-1", {
membershipRole: "owner",
status: "active",
grants: [],
});
await act(async () => {
@@ -382,4 +403,65 @@ describe("CompanyAccess", () => {
root.unmount();
});
});
it("redirects legacy access deep links to the permissions extension route when installed", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
routePath: "permissions",
pluginKey: "permissions-extension",
},
],
isLoading: false,
errorMessage: null,
});
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
await flushReact();
expect(mockNavigate).toHaveBeenCalledWith("/company/settings/permissions", true);
expect(container.textContent).toContain("/company/settings/permissions");
await act(async () => {
root.unmount();
});
});
it("shows a read-only unavailable fallback for legacy access deep links", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanyAccessLegacyRoute />
</QueryClientProvider>,
);
});
await flushReact();
expect(container.textContent).toContain("Advanced Permissions");
expect(container.textContent).toContain("Advanced permissions unavailable");
expect(container.textContent).toContain("Open Members");
expect(container.textContent).toContain("Open Invites");
await act(async () => {
root.unmount();
});
});
});
+77 -110
View File
@@ -2,17 +2,14 @@ import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS,
PERMISSION_KEYS,
type Agent,
type PermissionKey,
} from "@paperclipai/shared";
import { ShieldCheck, Trash2, Users } from "lucide-react";
import { Shield, ShieldCheck, Trash2, Users } from "lucide-react";
import { accessApi, type CompanyMember } from "@/api/access";
import { agentsApi } from "@/api/agents";
import { ApiError } from "@/api/client";
import { issuesApi } from "@/api/issues";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
@@ -25,38 +22,13 @@ import { Badge } from "@/components/ui/badge";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { useToast } from "@/context/ToastContext";
import { Link, Navigate } from "@/lib/router";
import { queryKeys } from "@/lib/queryKeys";
const permissionLabels: Record<PermissionKey, string> = {
"agents:create": "Create agents",
"users:invite": "Invite humans and agents",
"users:manage_permissions": "Manage members and grants",
"tasks:assign": "Assign tasks",
"tasks:assign_scope": "Assign scoped tasks",
"tasks:manage_active_checkouts": "Manage active task checkouts",
"joins:approve": "Approve join requests",
"environments:manage": "Manage environments",
};
function formatGrantSummary(member: CompanyMember) {
if (member.grants.length === 0) return "No explicit grants";
return member.grants.map((grant) => permissionLabels[grant.permissionKey]).join(", ");
}
const implicitRoleGrantMap: Record<NonNullable<CompanyMember["membershipRole"]>, PermissionKey[]> = {
owner: ["agents:create", "users:invite", "users:manage_permissions", "tasks:assign", "joins:approve"],
admin: ["agents:create", "users:invite", "tasks:assign", "joins:approve"],
operator: ["tasks:assign"],
viewer: [],
};
import { usePluginSlots } from "@/plugins/slots";
const reassignmentIssueStatuses = "backlog,todo,in_progress,in_review,blocked,failed,timed_out";
type EditableMemberStatus = "pending" | "active" | "suspended";
function getImplicitGrantKeys(role: CompanyMember["membershipRole"]) {
return role ? implicitRoleGrantMap[role] : [];
}
export function CompanyAccess() {
const { selectedCompany, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
@@ -67,13 +39,12 @@ export function CompanyAccess() {
const [reassignmentTarget, setReassignmentTarget] = useState<string>("__unassigned");
const [draftRole, setDraftRole] = useState<CompanyMember["membershipRole"]>(null);
const [draftStatus, setDraftStatus] = useState<EditableMemberStatus>("active");
const [draftGrants, setDraftGrants] = useState<Set<PermissionKey>>(new Set());
useEffect(() => {
setBreadcrumbs([
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
{ label: "Settings", href: "/company/settings" },
{ label: "Access" },
{ label: "Members" },
]);
}, [selectedCompany?.name, setBreadcrumbs]);
@@ -103,11 +74,10 @@ export function CompanyAccess() {
};
const updateMemberMutation = useMutation({
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus; grants: PermissionKey[] }) => {
return accessApi.updateMemberAccess(selectedCompanyId!, input.memberId, {
mutationFn: async (input: { memberId: string; membershipRole: CompanyMember["membershipRole"]; status: EditableMemberStatus }) => {
return accessApi.updateMember(selectedCompanyId!, input.memberId, {
membershipRole: input.membershipRole,
status: input.status,
grants: input.grants.map((permissionKey) => ({ permissionKey })),
});
},
onSuccess: async () => {
@@ -223,7 +193,6 @@ export function CompanyAccess() {
if (!editingMember) return;
setDraftRole(editingMember.membershipRole);
setDraftStatus(isEditableMemberStatus(editingMember.status) ? editingMember.status : "suspended");
setDraftGrants(new Set(editingMember.grants.map((grant) => grant.permissionKey)));
}, [editingMember]);
useEffect(() => {
@@ -255,8 +224,6 @@ export function CompanyAccess() {
joinRequestsQuery.data?.filter((request) => request.requestType === "human") ?? [];
const joinRequestActionPending =
approveJoinRequestMutation.isPending || rejectJoinRequestMutation.isPending;
const implicitGrantKeys = getImplicitGrantKeys(draftRole);
const implicitGrantSet = new Set(implicitGrantKeys);
const activeReassignmentUsers = members.filter(
(member) =>
member.status === "active" &&
@@ -271,11 +238,14 @@ export function CompanyAccess() {
<div className="space-y-3">
<div className="flex items-center gap-2">
<ShieldCheck className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Company Access</h1>
<h1 className="text-lg font-semibold">Company Members</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Manage company user memberships, membership status, and explicit permission grants for {selectedCompany?.name}.
Manage the people who can work in {selectedCompany?.name}. Members can collaborate across the company by default.
</p>
<div className="rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm text-muted-foreground">
Core keeps this page focused on membership, invite approvals, and safe member removal.
</div>
</div>
{access && !access.currentUserRole && (
@@ -291,7 +261,7 @@ export function CompanyAccess() {
<h2 className="text-base font-semibold">Humans</h2>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Manage human company memberships, status, and grants here.
Manage human company memberships and status here.
</p>
</div>
@@ -301,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>
@@ -340,11 +310,10 @@ export function CompanyAccess() {
) : null}
<div className="overflow-hidden rounded-xl border border-border">
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 text-xs font-medium uppercase tracking-wide text-muted-foreground">
<div>User account</div>
<div>Role</div>
<div>Status</div>
<div>Grants</div>
<div className="text-right">Action</div>
</div>
{members.length === 0 ? (
@@ -356,7 +325,7 @@ export function CompanyAccess() {
return (
<div
key={member.id}
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_minmax(0,1.2fr)_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
className="grid grid-cols-[minmax(0,1.5fr)_120px_120px_180px] gap-3 border-b border-border px-4 py-3 last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">{member.user?.name?.trim() || member.user?.email || member.principalId}</div>
@@ -372,7 +341,6 @@ export function CompanyAccess() {
{member.status.replace("_", " ")}
</Badge>
</div>
<div className="min-w-0 text-sm text-muted-foreground">{formatGrantSummary(member)}</div>
<div className="space-y-1 text-right">
<div className="flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setEditingMemberId(member.id)}>
@@ -405,7 +373,7 @@ export function CompanyAccess() {
<DialogHeader>
<DialogTitle>Edit member</DialogTitle>
<DialogDescription>
Update company role, membership status, and explicit grants for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
Update company role and membership status for {editingMember?.user?.name || editingMember?.user?.email || editingMember?.principalId}.
</DialogDescription>
</DialogHeader>
{editingMember && (
@@ -443,66 +411,6 @@ export function CompanyAccess() {
</select>
</label>
</div>
<div className="space-y-3">
<div>
<h3 className="text-sm font-medium">Grants</h3>
<p className="text-sm text-muted-foreground">
Roles provide implicit grants automatically. Explicit grants below are only for overrides and extra access that should persist even if the role changes.
</p>
</div>
<div className="rounded-lg border border-border px-3 py-3">
<div className="text-sm font-medium">Implicit grants from role</div>
<p className="mt-1 text-sm text-muted-foreground">
{draftRole
? `${HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole]} currently includes these permissions automatically.`
: "No role is selected, so this member has no implicit grants right now."}
</p>
{implicitGrantKeys.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{implicitGrantKeys.map((permissionKey) => (
<Badge key={permissionKey} variant="outline">
{permissionLabels[permissionKey]}
</Badge>
))}
</div>
) : null}
</div>
<div className="grid gap-3 md:grid-cols-2">
{PERMISSION_KEYS.map((permissionKey) => (
<label
key={permissionKey}
className="flex items-start gap-3 rounded-lg border border-border px-3 py-2"
>
<Checkbox
checked={draftGrants.has(permissionKey)}
onCheckedChange={(checked) => {
setDraftGrants((current) => {
const next = new Set(current);
if (checked) next.add(permissionKey);
else next.delete(permissionKey);
return next;
});
}}
/>
<span className="space-y-1">
<span className="block text-sm font-medium">{permissionLabels[permissionKey]}</span>
<span className="block text-xs text-muted-foreground">{permissionKey}</span>
{implicitGrantSet.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
Included implicitly by the {draftRole ? HUMAN_COMPANY_MEMBERSHIP_ROLE_LABELS[draftRole] : "selected"} role. Add an explicit grant only if it should stay after the role changes.
</span>
) : null}
{draftGrants.has(permissionKey) ? (
<span className="block text-xs text-muted-foreground">
Stored explicitly for this member.
</span>
) : null}
</span>
</label>
))}
</div>
</div>
</div>
)}
<DialogFooter>
@@ -516,12 +424,11 @@ export function CompanyAccess() {
memberId: editingMember.id,
membershipRole: draftRole,
status: draftStatus,
grants: [...draftGrants],
});
}}
disabled={updateMemberMutation.isPending}
>
{updateMemberMutation.isPending ? "Saving…" : "Save access"}
{updateMemberMutation.isPending ? "Saving…" : "Save member"}
</Button>
</DialogFooter>
</DialogContent>
@@ -616,6 +523,66 @@ export function CompanyAccess() {
);
}
export function CompanyAccessLegacyRoute() {
const { selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const { slots, isLoading, errorMessage } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: selectedCompanyId,
enabled: !!selectedCompanyId,
});
useEffect(() => {
setBreadcrumbs([
{ label: "Settings", href: "/company/settings" },
{ label: "Access" },
]);
}, [setBreadcrumbs]);
const permissionsSlot = slots.find((slot) => slot.routePath === "permissions");
if (permissionsSlot) {
return <Navigate to="/company/settings/permissions" replace />;
}
if (isLoading) {
return <div className="text-sm text-muted-foreground">Checking for advanced permission extensions...</div>;
}
return (
<div className="max-w-2xl space-y-5">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-muted-foreground" />
<h1 className="text-lg font-semibold">Advanced Permissions</h1>
</div>
<p className="text-sm text-muted-foreground">
Advanced access, scoped assignment, and explicit grant controls are provided by installed company settings extensions.
</p>
</div>
<div className="space-y-4 rounded-xl border border-border px-5 py-5">
<div className="space-y-2">
<h2 className="text-sm font-semibold">Advanced permissions unavailable</h2>
<p className="text-sm text-muted-foreground">
Core Paperclip keeps enforcing company boundaries and any existing restrictive policy data, but editing advanced permissions requires an installed extension.
</p>
{errorMessage ? (
<p className="text-sm text-destructive">Plugin extensions unavailable: {errorMessage}</p>
) : null}
</div>
<div className="flex flex-wrap gap-2">
<Button asChild>
<Link to="/company/settings/members">Open Members</Link>
</Button>
<Button asChild variant="outline">
<Link to="/company/settings/invites">Open Invites</Link>
</Button>
</div>
</div>
</div>
);
}
function memberDisplayName(member: CompanyMember | null) {
if (!member) return "this member";
return member.user?.name?.trim() || member.user?.email || member.principalId;
+4 -4
View File
@@ -945,7 +945,7 @@ export function CompanyExport() {
{/* Sticky top action bar */}
<div className="sticky top-0 z-10 border-b border-border bg-background px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-4 text-sm">
<div className="flex flex-wrap items-center gap-4 text-sm">
<span className="font-medium">
{selectedCompany?.name ?? "Company"} export
</span>
@@ -986,8 +986,8 @@ export function CompanyExport() {
)}
{/* Two-column layout */}
<div className="grid h-[calc(100vh-12rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="flex flex-col border-r border-border overflow-hidden">
<div className="grid gap-4 xl:h-[calc(100vh-12rem)] xl:grid-cols-[19rem_minmax(0,1fr)] xl:gap-0">
<aside className="flex max-h-[24rem] flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
@@ -1051,7 +1051,7 @@ export function CompanyExport() {
)}
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<div className="min-w-0 overflow-y-auto xl:pl-6">
<ExportPreviewPane selectedFile={selectedFile} content={previewContent} allFiles={effectiveFiles} onSkillClick={handleSkillClick} />
</div>
</div>
+5 -5
View File
@@ -1234,7 +1234,7 @@ export function CompanyImport() {
</select>
</Field>
<div className="flex items-center gap-2">
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
@@ -1294,7 +1294,7 @@ export function CompanyImport() {
/>
{/* Import button — below renames */}
<div className="mx-5 mt-3 flex justify-end">
<div className="mx-5 mt-3 flex flex-wrap justify-end gap-2">
<Button
size="sm"
onClick={() => importMutation.mutate()}
@@ -1338,8 +1338,8 @@ export function CompanyImport() {
)}
{/* Two-column layout */}
<div className="grid h-[calc(100vh-16rem)] gap-0 xl:grid-cols-[19rem_minmax(0,1fr)]">
<aside className="flex flex-col border-r border-border overflow-hidden">
<div className="grid gap-4 xl:h-[calc(100vh-16rem)] xl:grid-cols-[19rem_minmax(0,1fr)] xl:gap-0">
<aside className="flex max-h-[24rem] flex-col overflow-hidden border-b border-border xl:max-h-none xl:border-b-0 xl:border-r">
<div className="border-b border-border px-4 py-3 shrink-0">
<h2 className="text-base font-semibold">Package files</h2>
</div>
@@ -1358,7 +1358,7 @@ export function CompanyImport() {
/>
</div>
</aside>
<div className="min-w-0 overflow-y-auto pl-6">
<div className="min-w-0 overflow-y-auto xl:pl-6">
<ImportPreviewPane
selectedFile={selectedFile}
content={previewContent}
+77 -12
View File
@@ -93,12 +93,15 @@ describe("CompanyInvites", () => {
return Promise.resolve({ invites, nextOffset });
});
createCompanyInviteMock.mockResolvedValue({
inviteUrl: "https://paperclip.local/invite/new-token",
onboardingTextUrl: null,
onboardingTextPath: null,
humanRole: "viewer",
allowedJoinTypes: "human",
createCompanyInviteMock.mockImplementation(() => {
return Promise.resolve({
token: "new-token",
inviteUrl: "https://paperclip.local/invite/new-token",
onboardingTextUrl: null,
onboardingTextPath: null,
humanRole: "viewer",
allowedJoinTypes: "human",
});
});
revokeInviteMock.mockResolvedValue(undefined);
@@ -134,7 +137,9 @@ describe("CompanyInvites", () => {
await flushReact();
expect(container.textContent).toContain("Company Invites");
expect(container.textContent).toContain("Create invite");
expect(container.textContent).toContain("Invite a person");
expect(container.textContent).not.toContain("Invite an agent");
expect(container.textContent).not.toContain("Generate agent onboarding prompt");
expect(container.textContent).toContain("Invite history");
expect(container.textContent).toContain("Board User 25");
expect(container.textContent).toContain("Board User 21");
@@ -152,7 +157,8 @@ describe("CompanyInvites", () => {
expect(container.textContent).toContain("Choose a role");
expect(container.textContent).toContain("Each invite link is single-use.");
expect(container.textContent).toContain("Can create agents, invite users, assign tasks, and approve join requests.");
expect(container.textContent).toContain("Everything in Admin, plus managing members and permission grants.");
expect(container.textContent).toContain("Everything in Admin, plus managing members.");
expect(container.textContent).not.toContain("permission grants");
expect(listInvitesMock).toHaveBeenCalledWith("company-1", { limit: 5, offset: 0 });
const viewMoreButton = Array.from(container.querySelectorAll("button")).find(
@@ -197,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",
@@ -205,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();
@@ -229,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({
+83 -26
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";
@@ -14,8 +14,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
description: "Can view company work and follow along without operational permissions.",
gets: "No built-in grants.",
description: "Can view company work and follow along.",
gets: "View-only company membership.",
},
{
value: "operator",
@@ -32,8 +32,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
description: "Full company access, including membership and permission management.",
gets: "Everything in Admin, plus managing members and permission grants.",
description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members.",
},
] as const;
@@ -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,24 +62,60 @@ export function CompanyInvites() {
return () => window.clearTimeout(timeout);
}, [latestInviteCopied]);
async function copyInviteUrl(url: 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(url);
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// 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: "Copy the invite URL manually from the field below.",
body: unavailableBody,
tone: "warn",
});
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" },
@@ -117,7 +154,7 @@ export function CompanyInvites() {
onSuccess: async (invite) => {
setLatestInviteUrl(invite.inviteUrl);
setLatestInviteCopied(false);
const copied = await copyInviteUrl(invite.inviteUrl);
const copied = await copyText(invite.inviteUrl, "Copy the invite URL manually from the field below.");
await queryClient.invalidateQueries({ queryKey: inviteHistoryQueryKey });
pushToast({
@@ -176,13 +213,13 @@ export function CompanyInvites() {
<h1 className="text-lg font-semibold">Company Invites</h1>
</div>
<p className="max-w-3xl text-sm text-muted-foreground">
Create human invite links for company access. New invite links are copied to your clipboard when they are generated.
Invite people to request access to this company. New invite links are copied to your clipboard when they are generated.
</p>
</div>
<section className="space-y-4 rounded-xl border border-border p-5">
<div className="space-y-1">
<h2 className="text-sm font-semibold">Create invite</h2>
<h2 className="text-sm font-semibold">Invite a person</h2>
<p className="text-sm text-muted-foreground">
Generate a human invite link and choose the default access it should request.
</p>
@@ -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 copyInviteUrl(latestInviteUrl);
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" />
@@ -278,7 +329,7 @@ export function CompanyInvites() {
<div className="space-y-1">
<h2 className="text-sm font-semibold">Invite history</h2>
<p className="text-sm text-muted-foreground">
Review invite status, role, inviter, and any linked join request.
Review invite status, audience, inviter, and any linked join request.
</p>
</div>
<Link to="/inbox/requests" className="text-sm underline underline-offset-4">
@@ -297,7 +348,7 @@ export function CompanyInvites() {
<thead>
<tr className="border-b border-border">
<th className="px-5 py-3 font-medium text-muted-foreground">State</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Role</th>
<th className="px-5 py-3 font-medium text-muted-foreground">For</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Invited by</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Created</th>
<th className="px-5 py-3 font-medium text-muted-foreground">Join request</th>
@@ -312,7 +363,7 @@ export function CompanyInvites() {
{formatInviteState(invite.state)}
</span>
</td>
<td className="px-5 py-3 align-top">{invite.humanRole ?? "—"}</td>
<td className="px-5 py-3 align-top">{formatInviteAudience(invite)}</td>
<td className="px-5 py-3 align-top">
<div>{invite.invitedByUser?.name || invite.invitedByUser?.email || "Unknown inviter"}</div>
{invite.invitedByUser?.email && invite.invitedByUser.name ? (
@@ -372,3 +423,9 @@ export function CompanyInvites() {
function formatInviteState(state: "active" | "accepted" | "expired" | "revoked") {
return state.charAt(0).toUpperCase() + state.slice(1);
}
function formatInviteAudience(invite: Awaited<ReturnType<typeof accessApi.listInvites>>["invites"][number]) {
if (invite.allowedJoinTypes === "agent") return "Agent";
if (invite.allowedJoinTypes === "both") return invite.humanRole ? `Human or agent · ${invite.humanRole}` : "Human or agent";
return invite.humanRole ?? "Human";
}
+17 -268
View File
@@ -1,5 +1,5 @@
import { ChangeEvent, useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES,
MAX_COMPANY_ATTACHMENT_MAX_BYTES,
@@ -7,24 +7,17 @@ import {
import { useCompany } from "../context/CompanyContext";
import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { companiesApi } from "../api/companies";
import { accessApi } from "../api/access";
import { assetsApi } from "../api/assets";
import { instanceSettingsApi } from "../api/instanceSettings";
import { queryKeys } from "../lib/queryKeys";
import { Button } from "@/components/ui/button";
import { Settings, Check, Download, Upload } from "lucide-react";
import { Settings, CloudUpload, Download, Upload } from "lucide-react";
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
import {
Field,
ToggleField,
HintIcon,
} from "../components/agent-config-primitives";
type AgentSnippetInput = {
onboardingTextUrl: string;
connectionCandidates?: string[] | null;
testResolutionUrl?: string | null;
};
const BYTES_PER_MIB = 1024 * 1024;
const DEFAULT_COMPANY_ATTACHMENT_MAX_MIB = DEFAULT_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
const MAX_COMPANY_ATTACHMENT_MAX_MIB = MAX_COMPANY_ATTACHMENT_MAX_BYTES / BYTES_PER_MIB;
@@ -37,6 +30,10 @@ export function CompanySettings() {
} = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const queryClient = useQueryClient();
const { data: experimentalSettings } = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
});
// General settings local state
const [companyName, setCompanyName] = useState("");
const [description, setDescription] = useState("");
@@ -55,16 +52,12 @@ export function CompanySettings() {
setLogoUrl(selectedCompany.logoUrl ?? "");
}, [selectedCompany]);
const [inviteError, setInviteError] = useState<string | null>(null);
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
const [snippetCopied, setSnippetCopied] = useState(false);
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
const attachmentMaxBytes = Number.parseInt(attachmentMaxMiB, 10) * BYTES_PER_MIB;
const attachmentMaxValid =
Number.isInteger(attachmentMaxBytes)
&& attachmentMaxBytes >= BYTES_PER_MIB
&& attachmentMaxBytes <= MAX_COMPANY_ATTACHMENT_MAX_BYTES;
const cloudSyncEnabled = experimentalSettings?.enableCloudSync === true;
const generalDirty =
!!selectedCompany &&
@@ -95,59 +88,6 @@ export function CompanySettings() {
}
});
const inviteMutation = useMutation({
mutationFn: () =>
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
onSuccess: async (invite) => {
setInviteError(null);
const base = window.location.origin.replace(/\/+$/, "");
const onboardingTextLink =
invite.onboardingTextUrl ??
invite.onboardingTextPath ??
`/api/invites/${invite.token}/onboarding.txt`;
const absoluteUrl = onboardingTextLink.startsWith("http")
? onboardingTextLink
: `${base}${onboardingTextLink}`;
setSnippetCopied(false);
setSnippetCopyDelightId(0);
let snippet: string;
try {
const manifest = await accessApi.getInviteOnboarding(invite.token);
snippet = buildAgentSnippet({
onboardingTextUrl: absoluteUrl,
connectionCandidates:
manifest.onboarding.connectivity?.connectionCandidates ?? null,
testResolutionUrl:
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
null
});
} catch {
snippet = buildAgentSnippet({
onboardingTextUrl: absoluteUrl,
connectionCandidates: null,
testResolutionUrl: null
});
}
setInviteSnippet(snippet);
try {
await navigator.clipboard.writeText(snippet);
setSnippetCopied(true);
setSnippetCopyDelightId((prev) => prev + 1);
setTimeout(() => setSnippetCopied(false), 2000);
} catch {
/* clipboard may not be available */
}
queryClient.invalidateQueries({
queryKey: queryKeys.sidebarBadges(selectedCompanyId!)
});
},
onError: (err) => {
setInviteError(
err instanceof Error ? err.message : "Failed to create invite"
);
}
});
const syncLogoState = (nextLogoUrl: string | null) => {
setLogoUrl(nextLogoUrl ?? "");
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
@@ -184,13 +124,6 @@ export function CompanySettings() {
clearLogoMutation.mutate();
}
useEffect(() => {
setInviteError(null);
setInviteSnippet(null);
setSnippetCopied(false);
setSnippetCopyDelightId(0);
}, [selectedCompanyId]);
const archiveMutation = useMutation({
mutationFn: ({
companyId,
@@ -432,84 +365,6 @@ export function CompanySettings() {
</div>
</div>
{/* Invites */}
<div className="space-y-4" data-testid="company-settings-invites-section">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
Invites
</div>
<div className="space-y-3 rounded-md border border-border px-4 py-4">
<div className="flex items-center gap-1.5">
<span className="text-xs text-muted-foreground">
Generate an OpenClaw agent invite snippet.
</span>
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
data-testid="company-settings-invites-generate-button"
size="sm"
onClick={() => inviteMutation.mutate()}
disabled={inviteMutation.isPending}
>
{inviteMutation.isPending
? "Generating..."
: "Generate OpenClaw Invite Prompt"}
</Button>
</div>
{inviteError && (
<p className="text-sm text-destructive">{inviteError}</p>
)}
{inviteSnippet && (
<div
className="rounded-md border border-border bg-muted/30 p-2"
data-testid="company-settings-invites-snippet"
>
<div className="flex items-center justify-between gap-2">
<div className="text-xs text-muted-foreground">
OpenClaw Invite Prompt
</div>
{snippetCopied && (
<span
key={snippetCopyDelightId}
className="flex items-center gap-1 text-xs text-green-600 animate-pulse"
>
<Check className="h-3 w-3" />
Copied
</span>
)}
</div>
<div className="mt-1 space-y-1.5">
<textarea
data-testid="company-settings-invites-snippet-textarea"
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
value={inviteSnippet}
readOnly
/>
<div className="flex justify-end">
<Button
data-testid="company-settings-invites-copy-button"
size="sm"
variant="ghost"
onClick={async () => {
try {
await navigator.clipboard.writeText(inviteSnippet);
setSnippetCopied(true);
setSnippetCopyDelightId((prev) => prev + 1);
setTimeout(() => setSnippetCopied(false), 2000);
} catch {
/* clipboard may not be available */
}
}}
>
{snippetCopied ? "Copied snippet" : "Copy snippet"}
</Button>
</div>
</div>
</div>
)}
</div>
</div>
{/* Import / Export */}
<div className="space-y-4">
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
@@ -520,7 +375,15 @@ export function CompanySettings() {
Import and export have moved to dedicated pages accessible from the{" "}
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
</p>
<div className="mt-3 flex items-center gap-2">
<div className="mt-3 flex flex-wrap items-center gap-2">
{cloudSyncEnabled ? (
<Button size="sm" asChild>
<a href="/company/settings/cloud-upstream">
<CloudUpload className="mr-1.5 h-3.5 w-3.5" />
Send to Paperclip Cloud
</a>
</Button>
) : null}
<Button size="sm" variant="outline" asChild>
<a href="/company/export">
<Download className="mr-1.5 h-3.5 w-3.5" />
@@ -592,117 +455,3 @@ export function CompanySettings() {
</div>
);
}
function buildAgentSnippet(input: AgentSnippetInput) {
const candidateUrls = buildCandidateOnboardingUrls(input);
const resolutionTestUrl = buildResolutionTestUrl(input);
const candidateList =
candidateUrls.length > 0
? candidateUrls.map((u) => `- ${u}`).join("\n")
: "- (No candidate URLs available yet.)";
const connectivityBlock =
candidateUrls.length === 0
? `No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.
Suggested steps:
- choose a hostname that resolves to the Paperclip host from your runtime
- run: pnpm paperclipai allowed-hostname <host>
- restart Paperclip
- verify with: curl -fsS http://<host>:3100/api/health
- regenerate this invite snippet`
: `If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.
Suggested command:
- pnpm paperclipai allowed-hostname <host>
Then verify with: curl -fsS <base-url>/api/health`;
const resolutionLine = resolutionTestUrl
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
: "";
return `You're invited to join a Paperclip organization.
The URLs you should try are:
${candidateList}
Connectivity guidance:
If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.
Verify the hostname works from your runtime with: GET <base-url>/api/health
${connectivityBlock}
For your "agentName", just use your own agent name (OpenClaw or whatever your name is) and send it directly.
You also need to send a gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
---
This onboarding flow is for OpenClaw Gateway.
When you submit the join request, use:
- \`adapterType: "openclaw_gateway"\`
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
Then after you've connected to Paperclip (exchanged keys etc.) you MUST review and follow the onboarding instructions in onboarding.txt they give you.
`;
}
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
const candidates = (input.connectionCandidates ?? [])
.map((candidate) => candidate.trim())
.filter(Boolean);
const urls = new Set<string>();
let onboardingUrl: URL | null = null;
try {
onboardingUrl = new URL(input.onboardingTextUrl);
urls.add(onboardingUrl.toString());
} catch {
const trimmed = input.onboardingTextUrl.trim();
if (trimmed) {
urls.add(trimmed);
}
}
if (!onboardingUrl) {
for (const candidate of candidates) {
urls.add(candidate);
}
return Array.from(urls);
}
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
for (const candidate of candidates) {
try {
const base = new URL(candidate);
urls.add(`${base.origin}${onboardingPath}`);
} catch {
urls.add(candidate);
}
}
return Array.from(urls);
}
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
const explicit = input.testResolutionUrl?.trim();
if (explicit) return explicit;
try {
const onboardingUrl = new URL(input.onboardingTextUrl);
const testPath = onboardingUrl.pathname.replace(
/\/onboarding\.txt$/,
"/test-resolution"
);
return `${onboardingUrl.origin}${testPath}`;
} catch {
return null;
}
}
@@ -0,0 +1,140 @@
// @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";
import { CompanySettingsPluginPage } from "./CompanySettingsPluginPage";
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockParams = vi.hoisted(() => ({
companyPrefix: "PAP" as string | undefined,
settingsRoutePath: "permissions" as string | undefined,
}));
vi.mock("@/context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({
setBreadcrumbs: mockSetBreadcrumbs,
}),
}));
vi.mock("@/context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", name: "Paperclip", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
}),
}));
vi.mock("@/lib/router", () => ({
Link: ({ to, children }: { to: string; children: React.ReactNode }) => <a href={to}>{children}</a>,
useLocation: () => ({ pathname: "/PAP/company/settings/permissions", search: "", hash: "" }),
useParams: () => mockParams,
}));
vi.mock("@/plugins/slots", () => ({
usePluginSlots: mockUsePluginSlots,
PluginSlotMount: ({
slot,
context,
}: {
slot: { displayName: string };
context: { companyId: string | null; companyPrefix: string | null };
}) => (
<div data-testid="plugin-slot-mount">
{slot.displayName}:{context.companyId}:{context.companyPrefix}
</div>
),
}));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
async function renderPage(container: HTMLDivElement) {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<CompanySettingsPluginPage />
</QueryClientProvider>,
);
});
await flushReact();
return root;
}
describe("CompanySettingsPluginPage", () => {
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockParams.companyPrefix = "PAP";
mockParams.settingsRoutePath = "permissions";
mockUsePluginSlots.mockReturnValue({
slots: [
{
type: "companySettingsPage",
id: "permissions",
displayName: "Permissions",
exportName: "PermissionsPage",
routePath: "permissions",
pluginId: "plugin-1",
pluginKey: "permissions-extension",
pluginDisplayName: "Permissions Extension",
pluginVersion: "0.1.0",
},
],
isLoading: false,
errorMessage: null,
});
});
afterEach(() => {
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
it("mounts the matching company settings slot with company context", async () => {
const root = await renderPage(container);
expect(container.querySelector('[data-testid="plugin-slot-mount"]')?.textContent).toBe(
"Permissions:company-1:PAP",
);
expect(mockSetBreadcrumbs).toHaveBeenCalledWith([
{ label: "Settings", href: "/company/settings" },
{ label: "Permissions" },
]);
await act(async () => {
root.unmount();
});
});
it("fails closed when no ready plugin declares the route", async () => {
mockUsePluginSlots.mockReturnValue({
slots: [],
isLoading: false,
errorMessage: null,
});
const root = await renderPage(container);
expect(container.textContent).toContain("Page not found");
await act(async () => {
root.unmount();
});
});
});
@@ -0,0 +1,88 @@
import { useEffect, useMemo } from "react";
import { useParams } from "@/lib/router";
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
import { useCompany } from "@/context/CompanyContext";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { NotFoundPage } from "./NotFound";
export function CompanySettingsPluginPage() {
const params = useParams<{
companyPrefix?: string;
settingsRoutePath?: string;
}>();
const { companyPrefix: routeCompanyPrefix, settingsRoutePath } = params;
const { companies, selectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const routeCompany = useMemo(() => {
if (!routeCompanyPrefix) return null;
const requested = routeCompanyPrefix.toUpperCase();
return companies.find((company) => company.issuePrefix.toUpperCase() === requested) ?? null;
}, [companies, routeCompanyPrefix]);
const hasInvalidCompanyPrefix = Boolean(routeCompanyPrefix) && !routeCompany;
const resolvedCompanyId = routeCompany?.id ?? (routeCompanyPrefix ? null : selectedCompanyId ?? null);
const companyPrefix = resolvedCompanyId
? companies.find((company) => company.id === resolvedCompanyId)?.issuePrefix ?? null
: null;
const { slots, isLoading, errorMessage } = usePluginSlots({
slotTypes: ["companySettingsPage"],
companyId: resolvedCompanyId,
enabled: Boolean(resolvedCompanyId && settingsRoutePath),
});
const pageSlots = useMemo(() => {
if (!settingsRoutePath) return [];
return slots.filter((slot) => slot.routePath === settingsRoutePath);
}, [settingsRoutePath, slots]);
const pageSlot = pageSlots.length === 1 ? pageSlots[0] : null;
useEffect(() => {
if (!pageSlot) return;
setBreadcrumbs([
{ label: "Settings", href: "/company/settings" },
{ label: pageSlot.displayName },
]);
}, [pageSlot, setBreadcrumbs]);
if (!resolvedCompanyId) {
if (hasInvalidCompanyPrefix) {
return <NotFoundPage scope="invalid_company_prefix" requestedPrefix={routeCompanyPrefix} />;
}
return <div className="text-sm text-muted-foreground">Select a company to view this page.</div>;
}
if (!settingsRoutePath || isLoading) {
return <div className="text-sm text-muted-foreground">Loading...</div>;
}
if (errorMessage) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Plugin extensions unavailable: {errorMessage}
</div>
);
}
if (pageSlots.length > 1) {
return (
<div className="rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive">
Multiple plugins declare the company settings route <code>{settingsRoutePath}</code>. Disable one plugin or change its route.
</div>
);
}
if (!pageSlot) {
return <NotFoundPage scope="board" />;
}
return (
<PluginSlotMount
slot={pageSlot}
context={{ companyId: resolvedCompanyId, companyPrefix }}
className="min-h-[200px]"
missingBehavior="placeholder"
/>
);
}
+61
View File
@@ -124,6 +124,7 @@ import { InlineEditor } from "@/components/InlineEditor";
import { PageSkeleton } from "@/components/PageSkeleton";
import { Identity } from "@/components/Identity";
import { IssueReferencePill } from "@/components/IssueReferencePill";
import { MembershipAction } from "@/components/MembershipAction";
/* ------------------------------------------------------------------ */
/* Section wrapper */
@@ -896,6 +897,66 @@ export function DesignGuide() {
selected
/>
</div>
<SubSection title="Membership action">
<div className="border border-border rounded-md">
<EntityRow
title="Joined resource"
subtitle="Hover or focus the row to reveal the reserved action slot."
className="group"
trailing={
<MembershipAction
state="joined"
resourceName="Joined resource"
onJoin={() => {}}
onLeave={() => {}}
/>
}
/>
<EntityRow
title="Left resource"
subtitle="Persistent action with dimmed row content."
className="group text-foreground/55"
trailing={
<MembershipAction
state="left"
resourceName="Left resource"
onJoin={() => {}}
onLeave={() => {}}
/>
}
/>
<EntityRow
title="Leaving resource"
subtitle="Disabled while the optimistic mutation is pending."
className="group text-foreground/55"
trailing={
<MembershipAction
state="left"
pending
pendingState="left"
resourceName="Leaving resource"
onJoin={() => {}}
onLeave={() => {}}
/>
}
/>
<EntityRow
title="Joining resource"
subtitle="The target state is visible immediately while the server confirms."
className="group"
trailing={
<MembershipAction
state="joined"
pending
pendingState="joined"
resourceName="Joining resource"
onJoin={() => {}}
onLeave={() => {}}
/>
}
/>
</div>
</SubSection>
</Section>
{/* ============================================================ */}
@@ -0,0 +1,321 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExecutionWorkspace, 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 { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
listWorkspaceOperations: vi.fn(),
controlRuntimeCommands: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({ get: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
const mockRoutinesApi = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), run: vi.fn() }));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotOutlet = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
const mockPluginSlotState = vi.hoisted(() => ({
slots: [] as unknown[],
isLoading: false,
errorMessage: null as string | null,
}));
const mockRouteLocation = vi.hoisted(() => ({
pathname: "/execution-workspaces/workspace-1/issues",
search: "",
}));
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
useLocation: () => ({ ...mockRouteLocation, hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: vi.fn(),
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
PluginSlotOutlet: (props: unknown) => {
mockPluginSlotOutlet(props);
return <div data-testid="plugin-slot-outlet" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
const entityType = (filters as { entityType?: string }).entityType;
return {
slots: entityType === "execution_workspace" ? mockPluginSlotState.slots : [],
isLoading: mockPluginSlotState.isLoading,
errorMessage: mockPluginSlotState.errorMessage,
};
},
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: () => <div data-testid="issues-list" />,
}));
vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({
ExecutionWorkspaceCloseDialog: () => null,
}));
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
}));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeQuickControls: () => <div data-testid="runtime-quick-controls" />,
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button key={item.value} data-tab-value={item.value} type="button">{item.label}</button>
))}
</div>
),
}));
vi.mock("../components/CopyText", () => ({ CopyText: () => null }));
function workspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "local",
strategyType: "local_worktree",
name: "Diff worktree",
status: "active",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: null,
branchName: null,
providerType: "local",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: now,
openedAt: now,
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: now,
updatedAt: now,
...overrides,
} as ExecutionWorkspace;
}
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: "Test 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,
managedByPlugin: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function pluginSlot(overrides: Record<string, unknown> = {}) {
return {
id: "changes-tab",
type: "detailTab",
displayName: "Changes",
exportName: "ExecutionWorkspaceChangesTab",
entityTypes: ["execution_workspace"],
pluginId: "plugin-1",
pluginKey: "paperclip.workspace-diff",
pluginDisplayName: "Workspace Changes",
pluginVersion: "0.1.0",
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
describe("ExecutionWorkspaceDetail plugin slots", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace());
mockExecutionWorkspacesApi.listWorkspaceOperations.mockResolvedValue([]);
mockProjectsApi.get.mockResolvedValue(project());
mockIssuesApi.list.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
mockRoutinesApi.list.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
mockPluginSlotState.slots = [];
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = null;
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
mockRouteLocation.pathname = "/execution-workspaces/workspace-1/issues";
mockRouteLocation.search = "";
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ExecutionWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => {
await render();
const enabledDetailTabFilters = mockUsePluginSlots.mock.calls
.map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean })
.filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false);
expect(enabledDetailTabFilters.length).toBeGreaterThan(0);
for (const filters of enabledDetailTabFilters) {
expect(filters.entityType).toBe("execution_workspace");
expect(filters.companyId).toBe("company-1");
}
});
it("mounts a toolbar PluginSlotOutlet with execution_workspace context", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as {
slotTypes: string[];
entityType: string;
context: { entityId: string; entityType: string; companyId: string; projectId: string };
});
const toolbarOutlet = outletCalls.find((props) => props.slotTypes.includes("toolbarButton"));
expect(toolbarOutlet).toBeDefined();
expect(toolbarOutlet?.entityType).toBe("execution_workspace");
expect(toolbarOutlet?.context).toMatchObject({
entityId: "workspace-1",
entityType: "execution_workspace",
companyId: "company-1",
projectId: "project-1",
});
});
it("does not mount plugin slots scoped to other entity types", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as { entityType: string });
for (const props of outletCalls) {
expect(props.entityType).toBe("execution_workspace");
}
});
it("shows a missing plugin placeholder instead of routines for stale plugin tab URLs", async () => {
mockRouteLocation.pathname = "/execution-workspaces/workspace-1";
mockRouteLocation.search = "?tab=plugin%3Amissing%3Aslot";
await render();
expect(container.textContent).toContain("Workspace plugin tab is not available.");
expect(container.querySelector('a[href="/execution-workspaces/workspace-1/issues"]')?.textContent).toBe("Back to issues");
expect(container.textContent).not.toContain("Workspace routines");
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
});
it("orders execution workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "default-tab", displayName: "Default" }),
pluginSlot({ id: "changes-tab", displayName: "Changes", order: 25 }),
pluginSlot({ id: "inspect-tab", displayName: "Inspect", order: 50 }),
];
await render();
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
expect(tabLabels).toEqual([
"Issues",
"Services",
"Changes",
"Configuration",
"Runtime logs",
"Inspect",
"Routines",
"Default",
]);
});
});
+106 -12
View File
@@ -11,6 +11,7 @@ import { Tabs } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { CopyText } from "../components/CopyText";
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
import { agentsApi } from "../api/agents";
import { executionWorkspacesApi } from "../api/execution-workspaces";
import { heartbeatsApi } from "../api/heartbeats";
@@ -19,6 +20,7 @@ import { projectsApi } from "../api/projects";
import { routinesApi } from "../api/routines";
import { IssuesList } from "../components/IssuesList";
import { PageTabBar } from "../components/PageTabBar";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData,
@@ -54,9 +56,36 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspacePluginTab = `plugin:${string}`;
type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab;
type OrderedExecutionWorkspaceTabItem = {
value: ExecutionWorkspaceTab;
label: string;
order: number;
};
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100;
const EXECUTION_WORKSPACE_BASE_TAB_ITEMS: OrderedExecutionWorkspaceTabItem[] = [
{ value: "issues", label: "Issues", order: 10 },
{ value: "services", label: "Services", order: 20 },
{ value: "configuration", label: "Configuration", order: 30 },
{ value: "runtime_logs", label: "Runtime logs", order: 40 },
{ value: "routines", label: "Routines", order: 60 },
];
function isExecutionWorkspacePluginTab(value: string | null): value is ExecutionWorkspacePluginTab {
return typeof value === "string" && value.startsWith("plugin:");
}
function orderExecutionWorkspaceTabItems(items: OrderedExecutionWorkspaceTabItem[]) {
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => left.item.order - right.item.order || left.index - right.index)
.map(({ item }) => item);
}
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceBaseTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
@@ -69,7 +98,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
return null;
}
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceTab) {
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) {
const segment = tab === "runtime_logs" ? "runtime-logs" : tab;
return `/execution-workspaces/${workspaceId}/${segment}`;
}
@@ -536,7 +565,12 @@ export function ExecutionWorkspaceDetail() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
const pluginTabFromSearch = useMemo(() => {
const tab = new URLSearchParams(location.search).get("tab");
return isExecutionWorkspacePluginTab(tab) ? tab : null;
}, [location.search]);
const activeTab: ExecutionWorkspaceTab | null = activeRouteTab ?? pluginTabFromSearch;
const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
@@ -580,6 +614,30 @@ export function ExecutionWorkspaceDetail() {
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
[project, workspace?.projectWorkspaceId],
);
const {
slots: workspacePluginDetailSlots,
isLoading: workspacePluginDetailSlotsLoading,
errorMessage: workspacePluginDetailSlotsError,
} = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "execution_workspace",
companyId: workspace?.companyId ?? null,
enabled: !!workspace?.companyId,
});
const workspacePluginTabItems = useMemo(
() => workspacePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}` as ExecutionWorkspacePluginTab,
label: slot.displayName,
order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER,
slot,
})),
[workspacePluginDetailSlots],
);
const workspaceTabItems = useMemo(
() => orderExecutionWorkspaceTabItems([...EXECUTION_WORKSPACE_BASE_TAB_ITEMS, ...workspacePluginTabItems]),
[workspacePluginTabItems],
);
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
const runtimeConfigSource =
@@ -684,11 +742,23 @@ export function ExecutionWorkspaceDetail() {
});
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
const pluginSlotContext = {
companyId: workspace.companyId,
projectId: workspace.projectId,
entityId: workspace.id,
entityType: "execution_workspace" as const,
};
const activePluginTab = workspacePluginTabItems.find((item) => item.value === activeTab) ?? null;
if (workspaceId && activeTab === null) {
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
if (isExecutionWorkspacePluginTab(tab)) {
navigate(`/execution-workspaces/${workspace.id}?tab=${encodeURIComponent(tab)}`);
return;
}
navigate(executionWorkspaceTabPath(workspace.id, tab));
};
@@ -731,15 +801,18 @@ export function ExecutionWorkspaceDetail() {
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="execution_workspace"
context={pluginSlotContext}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
{ value: "issues", label: "Issues" },
{ value: "services", label: "Services" },
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
]}
items={workspaceTabItems.map((item) => ({ value: item.value, label: item.label }))}
align="start"
value={activeTab ?? "issues"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
@@ -1128,11 +1201,32 @@ export function ExecutionWorkspaceDetail() {
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
) : (
) : activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}
context={pluginSlotContext}
missingBehavior="placeholder"
/>
) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsLoading ? (
<Card>
<CardContent className="py-6 text-sm text-muted-foreground">Loading workspace plugin...</CardContent>
</Card>
) : isExecutionWorkspacePluginTab(activeTab) && workspacePluginDetailSlotsError ? (
<Card>
<CardContent className="py-6 text-sm text-destructive">{workspacePluginDetailSlotsError}</CardContent>
</Card>
) : isExecutionWorkspacePluginTab(activeTab) ? (
<MissingPluginTabPlaceholder
defaultTabHref={executionWorkspaceTabPath(workspace.id, "issues")}
defaultTabLabel="Back to issues"
/>
) : activeTab === "routines" ? (
<ExecutionWorkspaceRoutinesList
workspace={workspace}
project={project}
/>
) : (
<LegacyWorkspaceTabRedirect workspaceId={workspace.id} />
)}
</div>
<ExecutionWorkspaceCloseDialog
+58
View File
@@ -138,6 +138,11 @@ vi.mock("@/lib/router", () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
// jsdom doesn't implement scrollIntoView; the inbox calls it from a passive effect.
if (typeof Element !== "undefined" && !Element.prototype.scrollIntoView) {
Element.prototype.scrollIntoView = () => {};
}
function createIssue(overrides: Partial<Issue> = {}): Issue {
return {
id: "issue-1",
@@ -289,6 +294,59 @@ describe("Inbox toolbar", () => {
root.unmount();
});
});
it("syncs hover with j/k selection on inbox rows", async () => {
routerMock.location.pathname = "/inbox/mine";
const issueA = createIssue({ id: "issue-a", identifier: "PAP-1001", title: "First inbox row" });
const issueB = createIssue({ id: "issue-b", identifier: "PAP-1002", title: "Second inbox row" });
apiMocks.issuesList.mockResolvedValue([issueA, issueB]);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false, staleTime: 0, gcTime: 0 } },
});
const root = createRoot(container);
await act(async () => {
root.render(
<QueryClientProvider client={queryClient}>
<Inbox />
</QueryClientProvider>,
);
});
await act(async () => {
await Promise.resolve();
});
const rows = container.querySelectorAll("[data-inbox-item]");
expect(rows.length).toBeGreaterThanOrEqual(2);
const linkOf = (row: Element): HTMLAnchorElement | null =>
row.querySelector("a[data-inbox-issue-link]");
// Nothing selected before hover — both rows show the hover-accent class.
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
await act(async () => {
rows[1]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
// After hovering row 1, that row is "selected" — same visual state as j/k selection.
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-transparent");
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-accent/50");
await act(async () => {
rows[0]!.dispatchEvent(new MouseEvent("mouseover", { bubbles: true }));
});
// Hovering a different row moves the selection to follow the mouse.
expect(linkOf(rows[0]!)?.className).toContain("hover:bg-transparent");
expect(linkOf(rows[1]!)?.className).toContain("hover:bg-accent/50");
act(() => {
root.unmount();
});
});
});
describe("FailedRunInboxRow", () => {
+12 -1
View File
@@ -2326,6 +2326,7 @@ export function Inbox() {
depth === 0 && hasChildren && collapseParentId ? (
<button
type="button"
data-slot="icon-button"
className="hidden w-4 shrink-0 items-center justify-center sm:inline-flex"
onClick={(event) => {
event.preventDefault();
@@ -2358,6 +2359,7 @@ export function Inbox() {
depth === 0 && hasChildren && collapseParentId ? (
<button
type="button"
data-slot="icon-button"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
@@ -2438,6 +2440,9 @@ export function Inbox() {
onClick={() => {
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
}}
onMouseEnter={() => {
if (groupNavIdx >= 0) setSelectedIndex(groupNavIdx);
}}
>
<IssueGroupHeader
label={group.label}
@@ -2474,6 +2479,7 @@ export function Inbox() {
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(navIdx)}
onMouseEnter={() => setSelectedIndex(navIdx)}
>
{child}
</div>
@@ -2641,7 +2647,12 @@ export function Inbox() {
key={`sel-issue:${child.id}`}
data-inbox-item
className="relative"
onClick={() => setSelectedIndex(childNavIdx)}
onClick={() => {
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
}}
onMouseEnter={() => {
if (childNavIdx >= 0) setSelectedIndex(childNavIdx);
}}
>
{canArchiveIssue ? (
<SwipeToArchive
@@ -205,6 +205,7 @@ export function InstanceExperimentalSettings() {
const enableEnvironments = experimentalQuery.data?.enableEnvironments === true;
const enableIsolatedWorkspaces = experimentalQuery.data?.enableIsolatedWorkspaces === true;
const enableCloudSync = experimentalQuery.data?.enableCloudSync === true;
const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true;
const enableIssueGraphLivenessAutoRecovery =
experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true;
@@ -298,6 +299,24 @@ export function InstanceExperimentalSettings() {
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
<h2 className="text-sm font-semibold">Cloud Sync</h2>
<p className="max-w-2xl text-sm text-muted-foreground">
Show local Paperclip Cloud upstream connection, preview, push, retry, and activation review surfaces.
Saved connections and run history are preserved when this is disabled.
</p>
</div>
<ToggleSwitch
checked={enableCloudSync}
onCheckedChange={() => toggleMutation.mutate({ enableCloudSync: !enableCloudSync })}
disabled={toggleMutation.isPending}
aria-label="Toggle cloud sync experimental setting"
/>
</div>
</section>
<section className="rounded-xl border border-border bg-card p-5">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1.5">
+188 -11
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 () => {
@@ -403,16 +419,16 @@ describe("InviteLandingPage", () => {
expect(container.textContent).toContain("Request to join Acme Robotics");
expect(container.textContent).toContain("A company admin must approve your request to join.");
expect(container.textContent).toContain(
"Ask them to visit Company Settings → Access to approve your request.",
"Ask them to visit Company Settings → Members to approve your request.",
);
expect(container.querySelector('img[alt="Acme Robotics logo"]')).not.toBeNull();
expect(container.textContent).not.toContain("http://localhost/company/settings/access");
expect(container.textContent).not.toContain("http://localhost/company/settings/members");
const approvalLinks = Array.from(container.querySelectorAll("a")).filter(
(link) => link.textContent === "Company Settings → Access",
(link) => link.textContent === "Company Settings → Members",
);
expect(approvalLinks).toHaveLength(2);
const expectedApprovalUrl = `${window.location.origin}/company/settings/access`;
const expectedApprovalUrl = `${window.location.origin}/company/settings/members`;
for (const link of approvalLinks) {
expect(link.getAttribute("href")).toBe(expectedApprovalUrl);
}
@@ -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 → Access 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({
+39 -33
View File
@@ -8,7 +8,7 @@ import { useCompany } from "@/context/CompanyContext";
import { Link, useNavigate, useParams } from "@/lib/router";
import { accessApi } from "../api/access";
import { authApi } from "../api/auth";
import { companiesApi } from "../api/companies";
import { companiesListQueryOptions } from "../api/companies-query";
import { healthApi } from "../api/health";
import { getAdapterLabel } from "../adapters/adapter-display-registry";
import { clearPendingInviteToken, rememberPendingInviteToken } from "../lib/invite-memory";
@@ -160,7 +160,7 @@ function AwaitingJoinApprovalPanel({
claimApiKeyPath = null,
onboardingTextUrl = null,
}: AwaitingJoinApprovalPanelProps) {
const approvalUrl = `${window.location.origin}/company/settings/access`;
const approvalUrl = `${window.location.origin}/company/settings/members`;
const approverLabel = invitedByUserName ?? "A company admin";
return (
@@ -185,11 +185,11 @@ function AwaitingJoinApprovalPanel({
href={approvalUrl}
className="text-sm text-zinc-200 underline underline-offset-2 hover:text-zinc-100"
>
Company Settings Access
Company Settings Members
</a>
</div>
<p className="text-sm text-zinc-400">
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings Access</a> to approve your request.
Ask them to visit <a href={approvalUrl} className="text-zinc-200 underline underline-offset-2 hover:text-zinc-100">Company Settings Members</a> to approve your request.
</p>
<p className="text-xs text-zinc-500">
Refresh this page after you've been approved — you'll be redirected automatically.
@@ -248,11 +248,10 @@ export function InviteLandingPage() {
});
const companiesQuery = useQuery({
queryKey: queryKeys.companies.all,
queryFn: () => companiesApi.list(),
...companiesListQueryOptions,
enabled: !!sessionQuery.data && !!inviteQuery.data?.companyId,
retry: false,
});
const companyList = companiesQuery.data?.companies ?? [];
useEffect(() => {
if (token) rememberPendingInviteToken(token);
@@ -263,15 +262,12 @@ export function InviteLandingPage() {
}, [token]);
useEffect(() => {
if (!companiesQuery.data || !inviteQuery.data?.companyId) return;
const isMember = companiesQuery.data.some(
(c) => c.id === inviteQuery.data!.companyId
);
if (isMember) {
const list = companiesQuery.data?.companies;
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 =
@@ -280,9 +276,7 @@ export function InviteLandingPage() {
companiesQuery.isLoading;
const isCurrentMember =
Boolean(invite?.companyId) &&
Boolean(
companiesQuery.data?.some((company) => company.id === invite?.companyId),
);
companyList.some((company) => company.id === invite?.companyId);
const companyName = invite?.companyName?.trim() || null;
const companyDisplayName = companyName || "this Paperclip company";
const companyLogoUrl = invite?.companyLogoUrl?.trim() || null;
@@ -292,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 &&
@@ -301,7 +298,7 @@ export function InviteLandingPage() {
Boolean(sessionQuery.data) &&
!showsAgentForm &&
invite?.inviteType !== "bootstrap_ceo" &&
!inviteJoinRequestStatus &&
(!inviteJoinRequestStatus || canCompleteAcceptedHumanInvite) &&
!isCheckingExistingMembership &&
!isCurrentMember &&
!result &&
@@ -341,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" });
@@ -375,13 +373,10 @@ export function InviteLandingPage() {
setAuthFeedback(null);
rememberPendingInviteToken(token);
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
const companies = await queryClient.fetchQuery({
queryKey: queryKeys.companies.all,
queryFn: () => companiesApi.list(),
retry: false,
});
await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess });
const { companies: freshCompanies } = await queryClient.fetchQuery(companiesListQueryOptions);
if (invite?.companyId && companies.some((company) => company.id === invite.companyId)) {
if (invite?.companyId && freshCompanies.some((company) => company.id === invite.companyId)) {
clearPendingInviteToken(token);
setSelectedCompanyId(invite.companyId, { source: "manual" });
navigate("/", { replace: true });
@@ -413,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>;
@@ -451,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}
@@ -462,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">
@@ -787,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>
@@ -811,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>
+10 -10
View File
@@ -23,8 +23,8 @@ const inviteRoleOptions = [
{
value: "viewer",
label: "Viewer",
description: "Can view company work and follow along without operational permissions.",
gets: "No built-in grants.",
description: "Can view company work and follow along.",
gets: "View-only company membership.",
},
{
value: "operator",
@@ -41,8 +41,8 @@ const inviteRoleOptions = [
{
value: "owner",
label: "Owner",
description: "Full company access, including membership and permission management.",
gets: "Everything in Admin, plus managing members and permission grants.",
description: "Full company access, including membership management.",
gets: "Everything in Admin, plus managing members.",
},
] as const;
@@ -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}
@@ -423,8 +423,8 @@ function InviteResultPreview({
<>
<div className="border border-zinc-800 p-3">
<p className="mb-1 text-xs text-zinc-500">Approval page</p>
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/access">
Company Settings Access
<a className="text-sm text-zinc-200 underline underline-offset-2" href="/company/settings/members">
Company Settings Members
</a>
</div>
<p className="text-xs text-zinc-500">
@@ -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">
@@ -897,7 +897,7 @@ export function InviteUxLab() {
/>
<InviteResultPreview
title="Request to join Acme Robotics"
description="Ask them to visit Company Settings → Access to approve your request."
description="Ask them to visit Company Settings → Members to approve your request."
/>
</div>
</LabSection>
+10 -1
View File
@@ -617,6 +617,7 @@ type IssueDetailChatTabProps = {
blockedBy: Issue["blockedBy"];
blockerAttention: Issue["blockerAttention"] | null;
successfulRunHandoff: Issue["successfulRunHandoff"] | null;
scheduledRetry: Issue["scheduledRetry"] | null;
recoveryAction: Issue["activeRecoveryAction"];
onResolveRecoveryAction?: (outcome: import("../components/IssueRecoveryActionCard").RecoveryResolveOutcome) => void;
canFalsePositiveRecoveryAction?: boolean;
@@ -689,6 +690,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
blockedBy,
blockerAttention,
successfulRunHandoff,
scheduledRetry,
recoveryAction,
onResolveRecoveryAction,
canFalsePositiveRecoveryAction,
@@ -897,9 +899,11 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
timelineEvents={timelineEvents}
liveRuns={resolvedLiveRuns}
activeRun={resolvedActiveRun}
issueId={issueId}
blockedBy={blockedBy ?? []}
blockerAttention={blockerAttention}
successfulRunHandoff={successfulRunHandoff}
scheduledRetry={scheduledRetry}
recoveryAction={recoveryAction ?? null}
onResolveRecoveryAction={onResolveRecoveryAction}
canFalsePositiveRecoveryAction={canFalsePositiveRecoveryAction}
@@ -1770,7 +1774,7 @@ export function IssueDetail() {
mutationFn: (data: {
actionId?: string;
outcome: ResolveRecoveryActionOutcome;
sourceIssueStatus: "done" | "in_review" | "blocked";
sourceIssueStatus: "todo" | "done" | "in_review" | "blocked";
resolutionNote?: string | null;
}) => issuesApi.resolveRecoveryAction(issueId!, data),
onSuccess: ({ issue: nextIssue }) => {
@@ -3000,6 +3004,9 @@ export function IssueDetail() {
const actionId = activeRecoveryActionId;
if (!actionId) return;
switch (outcome) {
case "todo":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "todo" });
return;
case "done":
void resolveRecoveryAction.mutateAsync({ actionId, outcome: "restored", sourceIssueStatus: "done" });
return;
@@ -3709,6 +3716,7 @@ export function IssueDetail() {
<IssueDocumentsSection
issue={issue}
canDeleteDocuments={Boolean(session?.user?.id)}
canManageDocumentLocks={Boolean(session?.user?.id)}
feedbackVotes={feedbackVotes}
feedbackDataSharingPreference={feedbackDataSharingPreference}
feedbackTermsUrl={FEEDBACK_TERMS_URL}
@@ -3910,6 +3918,7 @@ export function IssueDetail() {
blockedBy={issue.blockedBy ?? []}
blockerAttention={issue.blockerAttention ?? null}
successfulRunHandoff={issue.successfulRunHandoff ?? null}
scheduledRetry={issue.scheduledRetry ?? null}
recoveryAction={issue.activeRecoveryAction ?? null}
onResolveRecoveryAction={handleResolveRecoveryAction}
canFalsePositiveRecoveryAction={canResolveBoardRecoveryAction}
+2
View File
@@ -145,6 +145,8 @@ export function Issues() {
includeRoutineExecutions: true,
limit: issuePageSize,
offset: pageParam,
sortField: "updated",
sortDir: "desc",
}),
initialPageParam: 0,
getNextPageParam: (lastPage, _allPages, lastPageParam) =>
+5 -5
View File
@@ -220,16 +220,16 @@ export function PluginManager() {
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Examples</Badge>
<Badge variant="outline">Bundled</Badge>
</div>
{examplesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
) : examplesQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
) : examples.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled example plugins were found in this checkout.
No bundled plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
@@ -246,7 +246,7 @@ export function PluginManager() {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{example.displayName}</span>
<Badge variant="outline">Example</Badge>
<Badge variant="outline">{example.tag === "first-party" ? "First-party" : "Example"}</Badge>
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
+28 -3
View File
@@ -2,7 +2,8 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { act, type ReactNode } from "react";
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProjectDetail } from "./ProjectDetail";
@@ -24,6 +25,10 @@ const mockBudgetsApi = vi.hoisted(() => ({ overview: vi.fn(), upsertPolicy: vi.f
const mockExecutionWorkspacesApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn() }));
const mockAssetsApi = vi.hoisted(() => ({ uploadImage: vi.fn() }));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateProject: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockIssuesList = vi.hoisted(() => vi.fn());
@@ -36,6 +41,7 @@ vi.mock("../api/budgets", () => ({ budgetsApi: mockBudgetsApi }));
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
vi.mock("../api/assets", () => ({ assetsApi: mockAssetsApi }));
vi.mock("../api/resourceMemberships", () => ({ resourceMembershipsApi: mockResourceMembershipsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
@@ -87,6 +93,14 @@ vi.mock("../components/IssuesList", () => ({
},
}));
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
@@ -152,10 +166,21 @@ describe("ProjectDetail", () => {
mockBudgetsApi.overview.mockResolvedValue({ policies: [] });
mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false });
mockExecutionWorkspacesApi.list.mockResolvedValue([]);
mockResourceMembershipsApi.listMine.mockResolvedValue({
projectMemberships: {},
agentMemberships: {},
updatedAt: null,
});
mockResourceMembershipsApi.updateProject.mockResolvedValue({
resourceType: "project",
resourceId: "project-1",
state: "left",
updatedAt: new Date("2026-05-01T00:00:00Z"),
});
});
afterEach(() => {
act(() => root?.unmount());
afterEach(async () => {
await act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
+62
View File
@@ -23,6 +23,7 @@ import { IssuesList } from "../components/IssuesList";
import { PageSkeleton } from "../components/PageSkeleton";
import { PageTabBar } from "../components/PageTabBar";
import { ProjectWorkspacesContent } from "../components/ProjectWorkspacesContent";
import { MembershipAction } from "../components/MembershipAction";
import { buildProjectWorkspaceSummaries } from "../lib/project-workspaces-tab";
import { collectLiveIssueIds } from "../lib/liveIssueIds";
import { projectRouteRef } from "../lib/utils";
@@ -30,6 +31,11 @@ import { Button } from "@/components/ui/button";
import { Tabs } from "@/components/ui/tabs";
import { PluginLauncherOutlet } from "@/plugins/launchers";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
/* ── Top-level tab types ── */
@@ -286,6 +292,7 @@ export function ProjectDetail() {
const navigate = useNavigate();
const location = useLocation();
const [fieldSaveStates, setFieldSaveStates] = useState<Partial<Record<ProjectConfigFieldKey, ProjectFieldSaveState>>>({});
const [dismissedLeftProjectIds, setDismissedLeftProjectIds] = useState<Set<string>>(() => new Set());
const fieldSaveRequestIds = useRef<Partial<Record<ProjectConfigFieldKey, number>>>({});
const fieldSaveTimers = useRef<Partial<Record<ProjectConfigFieldKey, ReturnType<typeof setTimeout>>>>({});
const routeProjectRef = projectId ?? "";
@@ -311,6 +318,11 @@ export function ProjectDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const projectLookupRef = project?.id ?? routeProjectRef;
const resolvedCompanyId = project?.companyId ?? selectedCompanyId;
const membershipsQuery = useResourceMemberships(resolvedCompanyId);
const membershipMutation = useResourceMembershipMutation(resolvedCompanyId);
const projectMembershipState = project?.id
? resourceMembershipState(membershipsQuery.data, "project", project.id)
: "joined";
const experimentalSettingsQuery = useQuery({
queryKey: queryKeys.instance.experimentalSettings,
queryFn: () => instanceSettingsApi.getExperimental(),
@@ -476,6 +488,16 @@ export function ProjectDetail() {
return () => closePanel();
}, [closePanel]);
useEffect(() => {
if (!project?.id || projectMembershipState !== "joined") return;
setDismissedLeftProjectIds((current) => {
if (!current.has(project.id)) return current;
const next = new Set(current);
next.delete(project.id);
return next;
});
}, [project?.id, projectMembershipState]);
useEffect(() => {
return () => {
Object.values(fieldSaveTimers.current).forEach((timer) => {
@@ -607,6 +629,12 @@ export function ProjectDetail() {
if (isLoading) return <PageSkeleton variant="detail" />;
if (error) return <p className="text-sm text-destructive">{error.message}</p>;
if (!project) return null;
const showLeftProjectNotice =
projectMembershipState === "left" && !dismissedLeftProjectIds.has(project.id);
const projectMembershipPending =
membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
const handleTabChange = (tab: ProjectTab) => {
// Cache the active tab per project
@@ -634,6 +662,40 @@ export function ProjectDetail() {
return (
<div className="space-y-6">
{showLeftProjectNotice ? (
<div className="flex items-center gap-3 border border-yellow-300/35 bg-yellow-300/10 px-3 py-2 text-sm text-yellow-100">
<p className="min-w-0 flex-1">
You left this project. It no longer appears in your sidebar.
</p>
<MembershipAction
compact
state="left"
pending={projectMembershipPending}
pendingState={projectMembershipPending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
})}
/>
<button
type="button"
className="h-6 w-6 shrink-0 text-yellow-100/70 hover:text-yellow-100"
aria-label="Dismiss project membership notice"
onClick={() => setDismissedLeftProjectIds((current) => new Set(current).add(project.id))}
>
×
</button>
</div>
) : null}
<div className="flex items-start gap-3">
<div className="h-7 flex items-center">
<ColorPicker
@@ -0,0 +1,341 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project, ProjectWorkspace } 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 { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockProjectsApi = vi.hoisted(() => ({
get: vi.fn(),
updateWorkspace: vi.fn(),
controlWorkspaceCommands: vi.fn(),
}));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
const mockRouteSearch = vi.hoisted(() => ({ value: "" }));
const mockPluginSlotState = vi.hoisted(() => ({
slots: [] as unknown[],
isLoading: false,
errorMessage: null as string | null,
}));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
<a href={to} className={className}>{children}</a>
),
useLocation: () => ({
pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1",
search: mockRouteSearch.value,
hash: "",
state: null,
}),
useNavigate: () => mockNavigate,
useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
const entityType = (filters as { entityType?: string }).entityType;
return {
slots: entityType === "project_workspace" ? mockPluginSlotState.slots : [],
isLoading: mockPluginSlotState.isLoading,
errorMessage: mockPluginSlotState.errorMessage,
};
},
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({
items,
onValueChange,
}: {
items: Array<{ value: string; label: string }>;
onValueChange?: (value: string) => void;
}) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button
key={item.value}
data-tab-value={item.value}
type="button"
onClick={() => onValueChange?.(item.value)}
>
{item.label}
</button>
))}
</div>
),
}));
function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Primary checkout",
sourceType: "local_path",
cwd: "/tmp/paperclip",
repoUrl: "https://github.com/paperclipai/paperclip",
repoRef: "master",
defaultRef: "origin/main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
runtimeServices: [],
isPrimary: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
const workspace = projectWorkspace();
return {
id: "project-1",
companyId: "company-1",
urlKey: "paperclip-app",
goalId: null,
goalIds: [],
goals: [],
name: "Paperclip App",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
defaultRef: workspace.defaultRef,
repoName: "paperclip",
localFolder: workspace.cwd,
managedFolder: workspace.cwd ?? "/tmp/paperclip",
effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [workspace],
primaryWorkspace: workspace,
managedByPlugin: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
function pluginSlot(overrides: Record<string, unknown> = {}) {
return {
id: "quality-tab",
type: "detailTab",
displayName: "Quality",
exportName: "ProjectWorkspaceQualityTab",
entityTypes: ["project_workspace"],
pluginId: "plugin-1",
pluginKey: "paperclip.quality",
pluginDisplayName: "Quality Plugin",
pluginVersion: "0.1.0",
...overrides,
};
}
describe("ProjectWorkspaceDetail plugin tabs", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.slots = [];
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = null;
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
mockRouteSearch.value = "";
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ProjectWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("scopes plugin detail-tab discovery to project_workspace and the project's company", async () => {
await render();
const enabledDetailTabFilters = mockUsePluginSlots.mock.calls
.map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean })
.filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false);
expect(enabledDetailTabFilters.length).toBeGreaterThan(0);
for (const filters of enabledDetailTabFilters) {
expect(filters.entityType).toBe("project_workspace");
expect(filters.companyId).toBe("company-1");
}
});
it("renders an arbitrary project_workspace plugin detail tab from the generic URL value", async () => {
mockPluginSlotState.slots = [pluginSlot()];
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab&diffView=head&baseRef=origin%2Fmaster";
await render();
expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration");
expect(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]')?.textContent).toBe("Quality");
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(mockPluginSlotMount).toHaveBeenCalledWith(
expect.objectContaining({
slot: expect.objectContaining({ pluginKey: "paperclip.quality", id: "quality-tab" }),
context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }),
}),
);
});
it("keeps the project workspace heading visible on plugin tabs", async () => {
mockPluginSlotState.slots = [pluginSlot({ displayName: "Changes" })];
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
await render();
expect(container.querySelector("h1")?.textContent).toBe("Primary checkout");
expect(container.textContent).toContain("Project workspace");
expect(container.textContent).toContain("This is the projects primary codebase workspace.");
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("orders project workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }),
pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }),
pluginSlot({ id: "default-tab", displayName: "Default" }),
];
await render();
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]);
});
it("navigates plugin tabs with only the generic plugin tab parameter", async () => {
mockPluginSlotState.slots = [pluginSlot()];
await render();
await act(async () => {
(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click();
});
expect(mockNavigate).toHaveBeenCalledWith(
"/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab",
);
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView"));
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef"));
});
it("does not treat the old changes tab query as a core plugin tab", async () => {
mockPluginSlotState.slots = [pluginSlot()];
mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain";
await render();
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).toContain("Project workspace");
});
it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => {
mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot";
await render();
expect(container.textContent).toContain("Workspace plugin tab is not available.");
expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe(
"Back to configuration",
);
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("shows loading and error states for plugin tab manifests", async () => {
mockPluginSlotState.isLoading = true;
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
await render();
expect(container.textContent).toContain("Loading workspace plugin...");
act(() => root?.unmount());
root = null;
container.innerHTML = "";
vi.clearAllMocks();
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = "Plugin manifest failed";
await render();
expect(container.textContent).toContain("Plugin manifest failed");
});
});
+139 -37
View File
@@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "@/lib/router";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import { ChoosePathButton } from "../components/PathInstructionsModal";
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
import { projectsApi } from "../api/projects";
import { PageTabBar } from "../components/PageTabBar";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import {
buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeControls,
@@ -35,6 +39,36 @@ type WorkspaceFormState = {
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
type ProjectWorkspaceBaseTab = "configuration";
type ProjectWorkspacePluginTab = `plugin:${string}`;
type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab;
type OrderedProjectWorkspaceTabItem = {
value: ProjectWorkspaceTab;
label: string;
order: number;
};
const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100;
const PROJECT_WORKSPACE_BASE_TAB_ITEMS: OrderedProjectWorkspaceTabItem[] = [
{ value: "configuration", label: "Configuration", order: 30 },
];
function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab {
return typeof value === "string" && value.startsWith("plugin:");
}
function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab {
const tab = new URLSearchParams(search).get("tab");
if (isProjectWorkspacePluginTab(tab)) return tab;
return "configuration";
}
function orderProjectWorkspaceTabItems(items: OrderedProjectWorkspaceTabItem[]) {
return items
.map((item, index) => ({ item, index }))
.sort((left, right) => left.item.order - right.item.order || left.index - right.index)
.map(({ item }) => item);
}
const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [
{ value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." },
@@ -217,6 +251,7 @@ export function ProjectWorkspaceDetail() {
}>();
const { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [form, setForm] = useState<WorkspaceFormState | null>(null);
@@ -224,6 +259,7 @@ export function ProjectWorkspaceDetail() {
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? "";
const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]);
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
@@ -247,6 +283,29 @@ export function ProjectWorkspaceDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
const {
slots: pluginDetailSlots,
isLoading: pluginDetailSlotsLoading,
errorMessage: pluginDetailSlotsError,
} = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "project_workspace",
companyId: project?.companyId ?? null,
enabled: Boolean(project?.companyId),
});
const pluginTabItems = useMemo(
() => pluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab,
label: slot.displayName,
order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER,
slot,
})),
[pluginDetailSlots],
);
const tabItems = useMemo(
() => orderProjectWorkspaceTabItems([...PROJECT_WORKSPACE_BASE_TAB_ITEMS, ...pluginTabItems]),
[pluginTabItems],
);
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
@@ -272,8 +331,8 @@ export function ProjectWorkspaceDetail() {
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
navigate(`${projectWorkspaceUrl(project, routeWorkspaceId)}${location.search}`, { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, navigate]);
const invalidateProject = () => {
if (!project) return;
@@ -363,6 +422,15 @@ export function ProjectWorkspaceDetail() {
};
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
const handleTabChange = (tab: ProjectWorkspaceTab) => {
const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId);
if (isProjectWorkspacePluginTab(tab)) {
navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`);
return;
}
navigate(workspacePath);
};
const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
return (
<div className="mx-auto max-w-5xl space-y-6">
@@ -373,45 +441,53 @@ export function ProjectWorkspaceDetail() {
Back to workspaces
</Link>
</Button>
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
</div>
</div>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Project workspace
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</div>
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
<PageTabBar
items={tabItems.map((item) => ({ value: item.value, label: item.label }))}
align="start"
value={activeTab}
onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5">
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
<div className="space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Project workspace
</div>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</div>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
<Separator className="my-5" />
@@ -643,6 +719,32 @@ export function ProjectWorkspaceDetail() {
</div>
</div>
</div>
) : null}
{isProjectWorkspacePluginTab(activeTab) ? (
activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: project.companyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
entityId: workspace.id,
entityType: "project_workspace",
}}
missingBehavior="placeholder"
/>
) : pluginDetailSlotsLoading || pluginDetailSlotsError ? (
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
{pluginDetailSlotsError ? pluginDetailSlotsError : "Loading workspace plugin..."}
</div>
) : (
<MissingPluginTabPlaceholder
defaultTabHref={`${projectWorkspaceUrl(project, routeWorkspaceId)}?tab=configuration`}
defaultTabLabel="Back to configuration"
/>
)
) : null}
</div>
);
}
+243
View File
@@ -0,0 +1,243 @@
// @vitest-environment jsdom
import type { ReactNode } from "react";
import { flushSync } from "react-dom";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Project } from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ToastProvider } from "../context/ToastContext";
import { Projects } from "./Projects";
const mockProjectsApi = vi.hoisted(() => ({
list: vi.fn(),
}));
const mockResourceMembershipsApi = vi.hoisted(() => ({
listMine: vi.fn(),
updateProject: vi.fn(),
}));
const mockOpenNewProject = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
vi.mock("@/lib/router", () => ({
Link: ({ children, to, ...props }: { children?: ReactNode; to: string }) => (
<a href={to} {...props}>{children}</a>
),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({ selectedCompanyId: "company-1" }),
}));
vi.mock("../context/DialogContext", () => ({
useDialogActions: () => ({ openNewProject: mockOpenNewProject }),
}));
vi.mock("../context/BreadcrumbContext", () => ({
useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }),
}));
vi.mock("../api/projects", () => ({
projectsApi: mockProjectsApi,
}));
vi.mock("../api/resourceMemberships", () => ({
resourceMembershipsApi: mockResourceMembershipsApi,
}));
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
if (!globalThis.PointerEvent) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).PointerEvent = MouseEvent;
}
async function act(callback: () => void | Promise<void>) {
let result: void | Promise<void> = undefined;
flushSync(() => {
result = callback();
});
await result;
}
function makeProject(overrides: Partial<Project>): Project {
return {
id: "project-a",
companyId: "company-1",
urlKey: "alpha",
goalId: null,
goalIds: [],
goals: [],
name: "Alpha",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#ef4444",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-a",
effectiveLocalFolder: "/tmp/project-a",
origin: "local_folder",
},
workspaces: [],
primaryWorkspace: null,
managedByPlugin: null,
archivedAt: null,
createdAt: new Date("2026-01-01T00:00:00Z"),
updatedAt: new Date("2026-01-01T00:00:00Z"),
...overrides,
};
}
async function flushReact() {
await act(async () => {
await Promise.resolve();
await new Promise((resolve) => window.setTimeout(resolve, 0));
});
}
describe("Projects", () => {
let container: HTMLDivElement;
let root: ReturnType<typeof createRoot> | null;
let queryClient: QueryClient;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = null;
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
});
mockProjectsApi.list.mockResolvedValue([
makeProject({
id: "project-c",
urlKey: "charlie",
name: "Charlie",
updatedAt: new Date("2026-01-10T00:00:00Z"),
}),
makeProject({
id: "project-b",
urlKey: "bravo",
name: "Bravo",
updatedAt: new Date("2026-01-05T00:00:00Z"),
}),
makeProject({
id: "project-a",
urlKey: "alpha",
name: "Alpha",
description: "First project",
updatedAt: new Date("2026-01-01T00:00:00Z"),
}),
]);
mockResourceMembershipsApi.listMine.mockResolvedValue({
projectMemberships: { "project-b": "left" },
agentMemberships: {},
updatedAt: null,
});
mockResourceMembershipsApi.updateProject.mockResolvedValue({
resourceType: "project",
resourceId: "project-b",
state: "joined",
updatedAt: new Date("2026-01-05T00:00:00Z"),
});
});
afterEach(async () => {
const currentRoot = root;
if (currentRoot) {
await act(async () => {
currentRoot.unmount();
});
}
queryClient.clear();
container.remove();
document.body.innerHTML = "";
vi.clearAllMocks();
});
async function renderProjects() {
const currentRoot = createRoot(container);
root = currentRoot;
await act(async () => {
currentRoot.render(
<QueryClientProvider client={queryClient}>
<ToastProvider>
<Projects />
</ToastProvider>
</QueryClientProvider>,
);
});
await flushReact();
await flushReact();
}
async function openSortMenu() {
const trigger = container.querySelector<HTMLButtonElement>('button[title="Sort"]');
expect(trigger).not.toBeNull();
await act(async () => {
trigger?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, button: 0 }));
trigger?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
async function chooseSortField(label: string) {
const item = Array.from(document.body.querySelectorAll("button"))
.find((element) => element.textContent?.includes(label));
expect(item).toBeTruthy();
await act(async () => {
item?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
});
await flushReact();
}
it("groups joined projects above left projects and defaults sorting by name", async () => {
await renderProjects();
const content = container.textContent ?? "";
expect(container.querySelector('button[title="Sort"]')?.textContent).toContain("Sort: Name");
expect(content.indexOf("My Projects")).toBeLessThan(content.indexOf("Alpha"));
expect(content.indexOf("Alpha")).toBeLessThan(content.indexOf("Charlie"));
expect(content.indexOf("Charlie")).toBeLessThan(content.indexOf("Other Projects"));
expect(content.indexOf("Other Projects")).toBeLessThan(content.indexOf("Bravo"));
expect(content).toContain("in progress");
});
it("sorts grouped projects by the selected field", async () => {
await renderProjects();
await openSortMenu();
await chooseSortField("Updated");
const content = container.textContent ?? "";
expect(content.indexOf("My Projects")).toBeLessThan(content.indexOf("Charlie"));
expect(content.indexOf("Charlie")).toBeLessThan(content.indexOf("Alpha"));
expect(content.indexOf("Alpha")).toBeLessThan(content.indexOf("Other Projects"));
});
it("reserves description line height for projects without descriptions", async () => {
await renderProjects();
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");
});
});
+187 -21
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";
@@ -7,16 +8,76 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
import { queryKeys } from "../lib/queryKeys";
import { EntityRow } from "../components/EntityRow";
import { StatusBadge } from "../components/StatusBadge";
import { MembershipAction } from "../components/MembershipAction";
import { EmptyState } from "../components/EmptyState";
import { PageSkeleton } from "../components/PageSkeleton";
import { formatDate, projectUrl } from "../lib/utils";
import {
resourceMembershipState,
useResourceMembershipMutation,
useResourceMemberships,
} from "../hooks/useResourceMemberships";
import { Button } from "@/components/ui/button";
import { Hexagon, Plus } from "lucide-react";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { ArrowUpDown, Check, Hexagon, Plus } from "lucide-react";
type ProjectSortField = "name" | "updated" | "created" | "targetDate";
type ProjectSortDir = "asc" | "desc";
const PROJECT_SORT_OPTIONS: Array<{ field: ProjectSortField; label: string }> = [
{ field: "name", label: "Name" },
{ field: "updated", label: "Updated" },
{ field: "created", label: "Created" },
{ field: "targetDate", label: "Target date" },
];
function compareProjectNames(left: Project, right: Project) {
const nameDiff = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
return nameDiff !== 0 ? nameDiff : left.id.localeCompare(right.id);
}
function projectTime(value: Date | string | null | undefined): number | null {
if (!value) return null;
const time = new Date(value).getTime();
return Number.isFinite(time) ? time : null;
}
function compareOptionalTime(
left: Date | string | null | undefined,
right: Date | string | null | undefined,
sortDir: ProjectSortDir,
) {
const leftTime = projectTime(left);
const rightTime = projectTime(right);
if (leftTime === null && rightTime === null) return 0;
if (leftTime === null) return 1;
if (rightTime === null) return -1;
return sortDir === "asc" ? leftTime - rightTime : rightTime - leftTime;
}
function sortProjects(projects: Project[], sortField: ProjectSortField, sortDir: ProjectSortDir) {
return [...projects].sort((left, right) => {
let comparison = 0;
if (sortField === "name") {
comparison = compareProjectNames(left, right);
return sortDir === "asc" ? comparison : -comparison;
}
if (sortField === "updated") comparison = compareOptionalTime(left.updatedAt, right.updatedAt, sortDir);
else if (sortField === "created") comparison = compareOptionalTime(left.createdAt, right.createdAt, sortDir);
else comparison = compareOptionalTime(left.targetDate, right.targetDate, sortDir);
if (comparison === 0) comparison = compareProjectNames(left, right);
return comparison;
});
}
export function Projects() {
const { selectedCompanyId } = useCompany();
const { openNewProject } = useDialogActions();
const { setBreadcrumbs } = useBreadcrumbs();
const [sortField, setSortField] = useState<ProjectSortField>("name");
const [sortDir, setSortDir] = useState<ProjectSortDir>("asc");
useEffect(() => {
setBreadcrumbs([{ label: "Projects" }]);
@@ -27,10 +88,31 @@ export function Projects() {
queryFn: () => projectsApi.list(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const membershipsQuery = useResourceMemberships(selectedCompanyId);
const membershipMutation = useResourceMembershipMutation(selectedCompanyId);
const projects = useMemo(
() => (allProjects ?? []).filter((p) => !p.archivedAt),
[allProjects],
);
const sortedProjects = useMemo(
() => sortProjects(projects, sortField, sortDir),
[projects, sortDir, sortField],
);
const groupedProjects = useMemo(() => {
const groups = {
mine: [] as typeof sortedProjects,
other: [] as typeof sortedProjects,
};
for (const project of sortedProjects) {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
if (state === "left") groups.other.push(project);
else groups.mine.push(project);
}
return groups;
}, [membershipsQuery.data, sortedProjects]);
const sortLabel = PROJECT_SORT_OPTIONS.find((option) => option.field === sortField)?.label ?? "Name";
if (!selectedCompanyId) {
return <EmptyState icon={Hexagon} message="Select a company to view projects." />;
@@ -42,7 +124,46 @@ export function Projects() {
return (
<div className="space-y-4">
<div className="flex items-center justify-end">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="w-fit text-xs" title="Sort">
<ArrowUpDown className="h-3.5 w-3.5 sm:h-3 sm:w-3 sm:mr-1" />
<span>Sort: {sortLabel}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-44 p-0">
<div className="p-2 space-y-0.5">
{PROJECT_SORT_OPTIONS.map((option) => (
<button
key={option.field}
type="button"
className={`flex w-full items-center justify-between rounded-sm px-2 py-1.5 text-sm ${
sortField === option.field
? "bg-accent/50 text-foreground"
: "text-muted-foreground hover:bg-accent/50"
}`}
onClick={() => {
if (sortField === option.field) {
setSortDir((current) => (current === "asc" ? "desc" : "asc"));
return;
}
setSortField(option.field);
setSortDir(option.field === "name" || option.field === "targetDate" ? "asc" : "desc");
}}
>
<span>{option.label}</span>
{sortField === option.field ? (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<Check className="h-3 w-3" />
{sortDir === "asc" ? "Asc" : "Desc"}
</span>
) : null}
</button>
))}
</div>
</PopoverContent>
</Popover>
<Button size="sm" variant="outline" onClick={openNewProject}>
<Plus className="h-4 w-4 mr-1" />
Add Project
@@ -61,25 +182,70 @@ export function Projects() {
)}
{projects.length > 0 && (
<div className="border border-border">
{projects.map((project) => (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
to={projectUrl(project)}
trailing={
<div className="flex items-center gap-3">
{project.targetDate && (
<span className="text-xs text-muted-foreground">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
<div className="space-y-6">
{([
["My Projects", groupedProjects.mine],
["Other Projects", groupedProjects.other],
] as const).map(([label, sectionProjects]) => {
if (sectionProjects.length === 0) return null;
return (
<section key={label} className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-medium">{label}</h2>
<span className="text-xs text-muted-foreground">
{sectionProjects.length} project{sectionProjects.length === 1 ? "" : "s"}
</span>
</div>
}
/>
))}
<div className="border border-border">
{sectionProjects.map((project) => {
const state = resourceMembershipState(membershipsQuery.data, "project", project.id);
const pending = membershipMutation.isPending &&
membershipMutation.variables?.resourceType === "project" &&
membershipMutation.variables.resourceId === project.id;
return (
<EntityRow
key={project.id}
title={project.name}
subtitle={project.description ?? undefined}
reserveSubtitleSpace
to={projectUrl(project)}
className={state === "left" ? "group text-foreground/55" : "group"}
trailing={
<div className="flex items-center gap-3">
{project.targetDate && (
<span className="text-xs text-muted-foreground">
{formatDate(project.targetDate)}
</span>
)}
<StatusBadge status={project.status} />
<MembershipAction
state={state}
pending={pending}
pendingState={pending ? membershipMutation.variables?.state : null}
resourceName={project.name}
onJoin={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "joined",
})}
onLeave={() => membershipMutation.mutate({
resourceType: "project",
resourceId: project.id,
resourceName: project.name,
state: "left",
})}
/>
</div>
}
/>
);
})}
</div>
</section>
);
})}
</div>
)}
</div>
+58 -2
View File
@@ -8,6 +8,7 @@ import {
Clock3,
Copy,
History as HistoryIcon,
KeyRound,
Play,
RefreshCw,
Repeat,
@@ -18,6 +19,8 @@ import {
} from "lucide-react";
import { ApiError } from "../api/client";
import { routinesApi, type RoutineTriggerResponse, type RotateRoutineTriggerResponse, type RestoreRoutineRevisionResponse } from "../api/routines";
import { secretsApi } from "../api/secrets";
import { EnvVarEditor } from "../components/EnvVarEditor";
import {
RoutineHistoryTab,
type RoutineHistoryDirtyFieldDescriptor,
@@ -63,13 +66,19 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Badge } from "@/components/ui/badge";
import type { RoutineDetail as RoutineDetailType, RoutineTrigger, RoutineVariable } from "@paperclipai/shared";
import type {
EnvBinding,
RoutineDetail as RoutineDetailType,
RoutineEnvConfig,
RoutineTrigger,
RoutineVariable,
} from "@paperclipai/shared";
const concurrencyPolicies = ["coalesce_if_active", "always_enqueue", "skip_if_active"];
const catchUpPolicies = ["skip_missed", "enqueue_missed_with_cap"];
const triggerKinds = ["schedule", "webhook"];
const signingModes = ["bearer", "hmac_sha256", "github_hmac", "none"];
const routineTabs = ["triggers", "runs", "activity", "history"] as const;
const routineTabs = ["triggers", "runs", "activity", "secrets", "history"] as const;
const concurrencyPolicyDescriptions: Record<string, string> = {
coalesce_if_active: "Keep one follow-up run queued while an active run is still working.",
always_enqueue: "Queue every trigger occurrence, even if several runs stack up.",
@@ -141,12 +150,14 @@ function buildRoutineMutationPayload(input: {
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
}) {
return {
...input,
description: input.description.trim() || null,
projectId: input.projectId || null,
assigneeAgentId: input.assigneeAgentId || null,
env: input.env && Object.keys(input.env).length > 0 ? input.env : null,
};
}
@@ -304,6 +315,7 @@ export function RoutineDetail() {
concurrencyPolicy: string;
catchUpPolicy: string;
variables: RoutineVariable[];
env: RoutineEnvConfig | null;
}>({
title: "",
description: "",
@@ -313,6 +325,7 @@ export function RoutineDetail() {
concurrencyPolicy: "coalesce_if_active",
catchUpPolicy: "skip_missed",
variables: [],
env: null,
});
const activeTab = useMemo(() => getRoutineTabFromSearch(location.search), [location.search]);
@@ -366,6 +379,21 @@ export function RoutineDetail() {
queryFn: () => accessApi.listUserDirectory(selectedCompanyId!),
enabled: !!selectedCompanyId,
});
const { data: availableSecrets = [] } = useQuery({
queryKey: selectedCompanyId ? queryKeys.secrets.list(selectedCompanyId) : ["secrets", "none"],
queryFn: () => secretsApi.list(selectedCompanyId!),
enabled: Boolean(selectedCompanyId),
});
const createSecret = useMutation({
mutationFn: (input: { name: string; value: string }) => {
if (!selectedCompanyId) throw new Error("Select a company to create secrets");
return secretsApi.create(selectedCompanyId, input);
},
onSuccess: () => {
if (!selectedCompanyId) return;
queryClient.invalidateQueries({ queryKey: queryKeys.secrets.list(selectedCompanyId) });
},
});
const routineDefaults = useMemo(
() =>
@@ -379,6 +407,7 @@ export function RoutineDetail() {
concurrencyPolicy: routine.concurrencyPolicy,
catchUpPolicy: routine.catchUpPolicy,
variables: routine.variables,
env: routine.env ?? null,
}
: null,
[routine],
@@ -408,6 +437,9 @@ export function RoutineDetail() {
if (JSON.stringify(editDraft.variables) !== JSON.stringify(routineDefaults.variables)) {
result.push({ key: "variables", label: "the variables" });
}
if (JSON.stringify(editDraft.env ?? null) !== JSON.stringify(routineDefaults.env ?? null)) {
result.push({ key: "env", label: "the secrets" });
}
return result;
}, [editDraft, routineDefaults]);
const isEditDirty = dirtyFields.length > 0;
@@ -1082,6 +1114,10 @@ export function RoutineDetail() {
<ActivityIcon className="h-3.5 w-3.5" />
Activity
</TabsTrigger>
<TabsTrigger value="secrets" className="gap-1.5">
<KeyRound className="h-3.5 w-3.5" />
Secrets
</TabsTrigger>
<TabsTrigger value="history" className="gap-1.5">
<HistoryIcon className="h-3.5 w-3.5" />
History
@@ -1226,6 +1262,24 @@ export function RoutineDetail() {
)}
</TabsContent>
<TabsContent value="secrets" className="space-y-3">
<p className="text-xs text-muted-foreground">
Routine secrets apply to every issue this routine creates. They override matching keys in
project and agent env. <span className="font-mono">PAPERCLIP_*</span> variables are reserved.
</p>
<EnvVarEditor
value={(editDraft.env ?? {}) as Record<string, EnvBinding>}
secrets={availableSecrets}
onCreateSecret={async (name, value) => {
const created = await createSecret.mutateAsync({ name, value });
return created;
}}
onChange={(env) =>
setEditDraft((current) => ({ ...current, env: env ?? null }))
}
/>
</TabsContent>
<TabsContent value="history">
<RoutineHistoryTab
routine={routine}
@@ -1241,6 +1295,7 @@ export function RoutineDetail() {
}}
agents={agentById}
projects={projectById}
secrets={availableSecrets}
onRestoreSecretMaterials={(response: RestoreRoutineRevisionResponse) => {
if (response.secretMaterials.length > 0) {
setSecretMessage({
@@ -1277,6 +1332,7 @@ export function RoutineDetail() {
concurrencyPolicy: response.routine.concurrencyPolicy,
catchUpPolicy: response.routine.catchUpPolicy,
variables: response.routine.variables,
env: response.routine.env ?? null,
});
hydratedRoutineIdRef.current = response.routine.id;
}}
+2 -1
View File
@@ -456,7 +456,7 @@ describe("Routines page", () => {
});
});
it("shows a row-level run now button on the routines table", async () => {
it("shows an outlined row-level run now button on the routines table", async () => {
routinesListMock.mockResolvedValue([createRoutine({ id: "routine-1", title: "Morning sync" })]);
issuesListMock.mockResolvedValue([]);
@@ -489,6 +489,7 @@ describe("Routines page", () => {
}
expect(runNowButton).toBeTruthy();
expect(runNowButton?.getAttribute("data-variant")).toBe("outline");
await act(async () => {
root.unmount();
+349 -1
View File
@@ -4,18 +4,25 @@ import { act } from "react";
import { createRoot } from "react-dom/client";
import { MemoryRouter } from "react-router-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { CompanySecretProviderConfig, SecretProviderDescriptor } from "@paperclipai/shared";
import type {
CompanySecretProviderConfig,
SecretProviderConfigDiscoveryPreviewResult,
SecretProviderDescriptor,
} from "@paperclipai/shared";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ProviderVaultsTab, Secrets } from "./Secrets";
import { ApiError } from "../api/client";
const mockSecretsApi = vi.hoisted(() => ({
list: vi.fn(),
providers: vi.fn(),
providerHealth: vi.fn(),
providerConfigs: vi.fn(),
providerConfigDiscoveryPreview: vi.fn(),
createProviderConfig: vi.fn(),
updateProviderConfig: vi.fn(),
disableProviderConfig: vi.fn(),
removeProviderConfig: vi.fn(),
setDefaultProviderConfig: vi.fn(),
checkProviderConfigHealth: vi.fn(),
create: vi.fn(),
@@ -133,6 +140,79 @@ async function flushReact() {
});
}
function makeDiscoveryPreview(
overrides: Partial<SecretProviderConfigDiscoveryPreviewResult> = {},
): SecretProviderConfigDiscoveryPreviewResult {
return {
provider: "aws_secrets_manager",
nextToken: null,
sampledSecretCount: 2,
skippedForeignPaperclipSampleCount: 0,
warnings: [],
candidates: [
{
provider: "aws_secrets_manager",
displayName: "AWS production",
config: {
region: "us-east-1",
namespace: "prod-use1",
secretNamePrefix: "paperclip",
kmsKeyId: "alias/paperclip-secrets",
ownerTag: "platform",
environmentTag: "production",
},
sampleCount: 2,
samples: [
{
name: "paperclip/prod-use1/company-1/openai",
hasKmsKey: true,
tagKeys: ["owner", "environment"],
},
],
signals: {
namespace: "prod-use1",
secretNamePrefix: "paperclip",
environmentTag: "production",
ownerTag: "platform",
kmsKeyId: "alias/paperclip-secrets",
hasKmsKey: true,
sampleCount: 2,
paperclipManagedSampleCount: 0,
skippedForeignPaperclipSampleCount: 0,
},
warnings: [],
},
],
...overrides,
};
}
function setInputValue(input: HTMLInputElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, "value")?.set;
setter?.call(input, value);
input.dispatchEvent(new Event("input", { bubbles: true }));
}
async function openAwsVaultDialog() {
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
) as HTMLButtonElement | undefined;
await act(async () => {
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
vaultTabButton?.click();
});
await flushReact();
const addVaultButtons = [...document.querySelectorAll("button")].filter(
(button) => button.textContent?.includes("Add vault"),
) as HTMLButtonElement[];
await act(async () => {
addVaultButtons[1]?.click();
});
await flushReact();
}
describe("Secrets page layout", () => {
let container: HTMLDivElement;
@@ -153,6 +233,7 @@ describe("Secrets page layout", () => {
],
});
mockSecretsApi.providerConfigs.mockResolvedValue(providerConfigs);
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValue(makeDiscoveryPreview());
});
afterEach(() => {
@@ -200,6 +281,7 @@ describe("Secrets page layout", () => {
onCreate={vi.fn()}
onEdit={vi.fn()}
onDisable={vi.fn()}
onRemove={vi.fn()}
onSetDefault={vi.fn()}
onHealthCheck={vi.fn()}
pendingActionId={null}
@@ -218,6 +300,64 @@ describe("Secrets page layout", () => {
});
});
it("warns that removing a provider vault only removes Paperclip config", async () => {
mockSecretsApi.removeProviderConfig.mockResolvedValueOnce(providerConfigs[1]);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const vaultTabButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Provider vaults"),
) as HTMLButtonElement | undefined;
await act(async () => {
vaultTabButton?.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true }));
vaultTabButton?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Enter" }));
vaultTabButton?.click();
});
await flushReact();
const removeButtons = [...document.querySelectorAll("button")].filter(
(button) => button.textContent?.trim() === "Remove",
) as HTMLButtonElement[];
await act(async () => {
removeButtons[1]?.click();
});
await flushReact();
expect(document.body.textContent).toContain("Remove provider vault");
expect(document.body.textContent).toContain("from Paperclip only");
expect(document.body.textContent).toContain("does not delete");
expect(document.body.textContent).toContain("AWS Secrets Manager");
const confirmButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Remove from Paperclip"),
) as HTMLButtonElement | undefined;
await act(async () => {
confirmButton?.click();
});
await flushReact();
expect(mockSecretsApi.removeProviderConfig).toHaveBeenCalledWith("vault-aws");
expect(mockSecretsApi.disableProviderConfig).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("opens reference details from the secrets table count", async () => {
mockSecretsApi.list.mockResolvedValue([
{
@@ -305,4 +445,212 @@ describe("Secrets page layout", () => {
root.unmount();
});
});
it("keeps the new secret value textarea width-constrained for long tokens", async () => {
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
const newSecretButton = Array.from(container.querySelectorAll("button")).find(
(button) => button.textContent?.includes("New secret"),
) as HTMLButtonElement | undefined;
expect(newSecretButton).toBeDefined();
await act(async () => {
newSecretButton?.click();
});
await flushReact();
const secretValueTextarea = document.body.querySelector("#new-secret-value") as HTMLTextAreaElement | null;
expect(secretValueTextarea).not.toBeNull();
expect(secretValueTextarea?.className).toContain("min-w-0");
expect(secretValueTextarea?.className).toContain("overflow-x-hidden");
expect(secretValueTextarea?.className).toContain("break-all");
await act(async () => {
root.unmount();
});
});
it("discovers AWS provider vault candidates and applies selected values as prefill", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(makeDiscoveryPreview());
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const discoveryButton = document.querySelector(
'[data-testid="aws-vault-discovery-button"]',
) as HTMLButtonElement | null;
expect(discoveryButton).not.toBeNull();
expect(discoveryButton?.disabled).toBe(true);
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement | null;
const prefixInput = document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement | null;
expect(regionInput).not.toBeNull();
await act(async () => {
setInputValue(regionInput!, "us-east-1");
setInputValue(prefixInput!, "paperclip");
});
await flushReact();
expect(discoveryButton?.disabled).toBe(false);
await act(async () => {
discoveryButton?.click();
});
await flushReact();
await flushReact();
expect(mockSecretsApi.providerConfigDiscoveryPreview).toHaveBeenCalledWith("company-1", {
provider: "aws_secrets_manager",
config: {
region: "us-east-1",
namespace: null,
secretNamePrefix: "paperclip",
kmsKeyId: null,
ownerTag: null,
environmentTag: null,
},
query: "paperclip",
pageSize: 25,
});
expect(document.body.textContent).toContain("AWS production");
const useValuesButton = [...document.querySelectorAll("button")].find(
(button) => button.textContent?.includes("Use values"),
) as HTMLButtonElement | undefined;
await act(async () => {
useValuesButton?.click();
});
await flushReact();
expect((document.getElementById("vault-name") as HTMLInputElement).value).toBe("AWS production");
expect((document.getElementById("provider-vault-namespace") as HTMLInputElement).value).toBe("prod-use1");
expect((document.getElementById("provider-vault-secret-name-prefix") as HTMLInputElement).value).toBe("paperclip");
expect((document.getElementById("provider-vault-kms-key-id") as HTMLInputElement).value).toBe("alias/paperclip-secrets");
expect((document.getElementById("provider-vault-owner-tag") as HTMLInputElement).value).toBe("platform");
expect((document.getElementById("provider-vault-environment-tag") as HTMLInputElement).value).toBe("production");
expect(mockSecretsApi.createProviderConfig).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
});
});
it("shows AWS discovery errors without replacing manual vault form values", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockRejectedValueOnce(
new ApiError("AWS Secrets Manager denied the request. Check IAM permissions for this provider vault.", 403, {
details: { code: "access_denied" },
}),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
const namespaceInput = document.getElementById("provider-vault-namespace") as HTMLInputElement;
await act(async () => {
setInputValue(regionInput, "us-west-2");
setInputValue(namespaceInput, "manual-prod");
});
await flushReact();
const discoveryButton = document.querySelector(
'[data-testid="aws-vault-discovery-button"]',
) as HTMLButtonElement | null;
await act(async () => {
discoveryButton?.click();
});
await flushReact();
await flushReact();
expect(document.body.textContent).toContain("AWS Secrets Manager denied the request");
expect(regionInput.value).toBe("us-west-2");
expect(namespaceInput.value).toBe("manual-prod");
await act(async () => {
root.unmount();
});
});
it("shows an empty AWS discovery result without blocking manual entry", async () => {
mockSecretsApi.providerConfigDiscoveryPreview.mockResolvedValueOnce(
makeDiscoveryPreview({ candidates: [], sampledSecretCount: 0 }),
);
const root = createRoot(container);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
await act(async () => {
root.render(
<MemoryRouter>
<QueryClientProvider client={queryClient}>
<Secrets />
</QueryClientProvider>
</MemoryRouter>,
);
});
await flushReact();
await flushReact();
await openAwsVaultDialog();
const regionInput = document.getElementById("provider-vault-aws-region") as HTMLInputElement;
await act(async () => {
setInputValue(regionInput, "us-east-2");
});
await flushReact();
await act(async () => {
(document.querySelector('[data-testid="aws-vault-discovery-button"]') as HTMLButtonElement | null)?.click();
});
await flushReact();
await flushReact();
expect(document.body.textContent).toContain("No AWS vault metadata candidates found");
expect(regionInput.value).toBe("us-east-2");
await act(async () => {
root.unmount();
});
});
});
+290 -1
View File
@@ -29,6 +29,8 @@ import type {
CompanySecret,
CompanySecretUsageBinding,
CompanySecretProviderConfig,
SecretProviderConfigDiscoveryCandidate,
SecretProviderConfigDiscoveryPreviewResult,
SecretAccessEvent,
SecretManagedMode,
SecretProvider,
@@ -325,6 +327,16 @@ function buildProviderVaultConfig(form: ProviderVaultForm): Record<string, unkno
}
}
function getAwsProviderVaultDiscoveryQuery(form: ProviderVaultForm): string | null {
return (
form.secretNamePrefix.trim() ||
form.namespace.trim() ||
form.environmentTag.trim() ||
form.ownerTag.trim() ||
null
);
}
export function getAwsManagedPathPreview(input: {
provider: SecretProviderDescriptor | null | undefined;
health: SecretProviderHealthResponse | null;
@@ -372,8 +384,12 @@ export function Secrets() {
const [deleteConfirm, setDeleteConfirm] = useState<CompanySecret | null>(null);
const [vaultDialogOpen, setVaultDialogOpen] = useState(false);
const [editingVault, setEditingVault] = useState<CompanySecretProviderConfig | null>(null);
const [removeVaultConfirm, setRemoveVaultConfirm] = useState<CompanySecretProviderConfig | null>(null);
const [vaultForm, setVaultForm] = useState<ProviderVaultForm>(() => emptyProviderVaultForm());
const [vaultError, setVaultError] = useState<string | null>(null);
const [vaultDiscovery, setVaultDiscovery] =
useState<SecretProviderConfigDiscoveryPreviewResult | null>(null);
const [vaultDiscoveryError, setVaultDiscoveryError] = useState<string | null>(null);
useEffect(() => {
setBreadcrumbs([{ label: "Secrets" }]);
@@ -648,6 +664,24 @@ export function Secrets() {
},
});
const discoverVaultMutation = useMutation({
mutationFn: () =>
secretsApi.providerConfigDiscoveryPreview(selectedCompanyId!, {
provider: "aws_secrets_manager",
config: buildProviderVaultConfig(vaultForm),
query: getAwsProviderVaultDiscoveryQuery(vaultForm),
pageSize: 25,
}),
onSuccess: (preview) => {
setVaultDiscovery(preview);
setVaultDiscoveryError(null);
},
onError: (error) => {
setVaultDiscovery(null);
setVaultDiscoveryError(error instanceof ApiError ? error.message : (error as Error).message);
},
});
const disableVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.disableProviderConfig(id),
onSuccess: (updated) => {
@@ -663,6 +697,26 @@ export function Secrets() {
},
});
const removeVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.removeProviderConfig(id),
onSuccess: (removed) => {
pushToast({
title: "Provider vault removed",
body: `${removed.displayName} was removed from Paperclip only.`,
tone: "info",
});
setRemoveVaultConfirm(null);
invalidateAll();
},
onError: (error) => {
pushToast({
title: "Remove failed",
body: error instanceof Error ? error.message : "Try again",
tone: "error",
});
},
});
const defaultVaultMutation = useMutation({
mutationFn: (id: string) => secretsApi.setDefaultProviderConfig(id),
onSuccess: (updated) => {
@@ -735,6 +789,8 @@ export function Secrets() {
setEditingVault(null);
setVaultForm(emptyProviderVaultForm(provider));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
@@ -742,9 +798,26 @@ export function Secrets() {
setEditingVault(config);
setVaultForm(providerVaultFormFromConfig(config));
setVaultError(null);
setVaultDiscovery(null);
setVaultDiscoveryError(null);
setVaultDialogOpen(true);
}
function applyVaultDiscoveryCandidate(candidate: SecretProviderConfigDiscoveryCandidate) {
if (candidate.provider !== "aws_secrets_manager") return;
const config = candidate.config as Record<string, unknown>;
setVaultForm((current) => ({
...current,
displayName: current.displayName.trim() ? current.displayName : candidate.displayName,
region: providerConfigValue(config, "region"),
namespace: providerConfigValue(config, "namespace"),
secretNamePrefix: providerConfigValue(config, "secretNamePrefix"),
kmsKeyId: providerConfigValue(config, "kmsKeyId"),
ownerTag: providerConfigValue(config, "ownerTag"),
environmentTag: providerConfigValue(config, "environmentTag"),
}));
}
if (!selectedCompanyId) {
return (
<div className="p-6 text-sm text-muted-foreground">Select a company to manage secrets.</div>
@@ -923,10 +996,12 @@ export function Secrets() {
onCreate={openCreateVault}
onEdit={openEditVault}
onDisable={(config) => disableVaultMutation.mutate(config.id)}
onRemove={(config) => setRemoveVaultConfirm(config)}
onSetDefault={(config) => defaultVaultMutation.mutate(config.id)}
onHealthCheck={(config) => healthVaultMutation.mutate(config.id)}
pendingActionId={
disableVaultMutation.variables ??
removeVaultMutation.variables ??
defaultVaultMutation.variables ??
healthVaultMutation.variables ??
null
@@ -1224,7 +1299,7 @@ export function Secrets() {
setCreateForm((current) => ({ ...current, value: event.target.value }))
}
rows={3}
className="font-mono text-xs"
className="min-w-0 overflow-x-hidden break-all font-mono text-xs"
placeholder="Stored once, never re-displayed"
/>
</div>
@@ -1305,6 +1380,8 @@ export function Secrets() {
onChange={(event) => {
const provider = event.target.value as SecretProvider;
setVaultForm(emptyProviderVaultForm(provider));
setVaultDiscovery(null);
setVaultDiscoveryError(null);
}}
>
{PROVIDER_ORDER.map((provider) => (
@@ -1367,6 +1444,21 @@ export function Secrets() {
<ProviderVaultFields form={vaultForm} onChange={setVaultForm} />
{!editingVault && vaultForm.provider === "aws_secrets_manager" ? (
<AwsProviderVaultDiscoveryPanel
form={vaultForm}
preview={vaultDiscovery}
error={vaultDiscoveryError}
loading={discoverVaultMutation.isPending}
onDiscover={() => {
setVaultDiscovery(null);
setVaultDiscoveryError(null);
discoverVaultMutation.mutate();
}}
onApply={applyVaultDiscoveryCandidate}
/>
) : null}
{vaultForm.provider === "gcp_secret_manager" || vaultForm.provider === "vault" ? (
<div className="rounded-md border border-sky-500/30 bg-sky-500/5 p-3 text-xs text-sky-700 dark:text-sky-300">
This provider can save draft routing metadata, but runtime writes and resolution stay disabled until
@@ -1510,6 +1602,32 @@ export function Secrets() {
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={Boolean(removeVaultConfirm)} onOpenChange={(open) => !open && setRemoveVaultConfirm(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Remove provider vault</DialogTitle>
<DialogDescription>
Removes <strong>{removeVaultConfirm?.displayName}</strong> from Paperclip only.{" "}
{removeVaultConfirm?.provider === "aws_secrets_manager"
? "This does not delete the remote AWS Secrets Manager vault, secrets, or any AWS data."
: "This does not delete any remote provider data."}{" "}
Secrets using this vault will lose the vault association until you assign another one.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRemoveVaultConfirm(null)}>Cancel</Button>
<Button
variant="destructive"
onClick={() => removeVaultConfirm && removeVaultMutation.mutate(removeVaultConfirm.id)}
disabled={removeVaultMutation.isPending}
>
{removeVaultMutation.isPending ? <Loader2 className="h-3.5 w-3.5 animate-spin mr-1" /> : null}
Remove from Paperclip
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -1748,6 +1866,7 @@ export function ProviderVaultsTab({
onCreate,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
pendingActionId,
@@ -1760,6 +1879,7 @@ export function ProviderVaultsTab({
onCreate: (provider: SecretProvider) => void;
onEdit: (config: CompanySecretProviderConfig) => void;
onDisable: (config: CompanySecretProviderConfig) => void;
onRemove: (config: CompanySecretProviderConfig) => void;
onSetDefault: (config: CompanySecretProviderConfig) => void;
onHealthCheck: (config: CompanySecretProviderConfig) => void;
pendingActionId: string | null;
@@ -1840,6 +1960,7 @@ export function ProviderVaultsTab({
pending={pendingActionId === config.id}
onEdit={() => onEdit(config)}
onDisable={() => onDisable(config)}
onRemove={() => onRemove(config)}
onSetDefault={() => onSetDefault(config)}
onHealthCheck={() => onHealthCheck(config)}
/>
@@ -1858,6 +1979,7 @@ function ProviderVaultCard({
pending,
onEdit,
onDisable,
onRemove,
onSetDefault,
onHealthCheck,
}: {
@@ -1865,6 +1987,7 @@ function ProviderVaultCard({
pending: boolean;
onEdit: () => void;
onDisable: () => void;
onRemove: () => void;
onSetDefault: () => void;
onHealthCheck: () => void;
}) {
@@ -1936,6 +2059,16 @@ function ProviderVaultCard({
<Ban className="h-3.5 w-3.5 mr-1" />
Disable
</Button>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={onRemove}
disabled={pending}
>
<Trash2 className="h-3.5 w-3.5 mr-1" />
Remove
</Button>
</div>
</div>
);
@@ -2002,6 +2135,162 @@ function ProviderVaultFields({
);
}
function AwsProviderVaultDiscoveryPanel({
form,
preview,
error,
loading,
onDiscover,
onApply,
}: {
form: ProviderVaultForm;
preview: SecretProviderConfigDiscoveryPreviewResult | null;
error: string | null;
loading: boolean;
onDiscover: () => void;
onApply: (candidate: SecretProviderConfigDiscoveryCandidate) => void;
}) {
const canDiscover = Boolean(form.region.trim());
const warnings = preview?.warnings ?? [];
return (
<div className="space-y-3 border-t border-border pt-3">
<div className="flex flex-wrap items-center gap-2">
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">AWS discovery</p>
<p className="text-xs text-muted-foreground">
Uses the current draft routing fields to inspect AWS Secrets Manager metadata. Values are not read.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={onDiscover}
disabled={!canDiscover || loading}
data-testid="aws-vault-discovery-button"
>
{loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin mr-1" />
) : (
<Search className="h-3.5 w-3.5 mr-1" />
)}
Find existing AWS values
</Button>
</div>
{!canDiscover ? (
<p className="text-xs text-muted-foreground">Enter an AWS region before discovery.</p>
) : null}
{loading ? (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/20 p-3 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Searching AWS Secrets Manager metadata
</div>
) : null}
{error ? (
<div
className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 p-3 text-xs text-destructive"
role="alert"
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{error}</span>
</div>
) : null}
{warnings.length > 0 ? (
<div className="space-y-1 rounded-md border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-amber-700 dark:text-amber-300">
{warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
{preview && preview.candidates.length === 0 && !loading ? (
<div className="rounded-md border border-dashed border-border bg-muted/20 p-3 text-xs text-muted-foreground">
No AWS vault metadata candidates found. Manual entry is still available.
</div>
) : null}
{preview && preview.candidates.length > 0 ? (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Database className="h-3.5 w-3.5" />
<span>
{preview.candidates.length} candidate{preview.candidates.length === 1 ? "" : "s"} from{" "}
{preview.sampledSecretCount} sampled secret{preview.sampledSecretCount === 1 ? "" : "s"}
</span>
</div>
<div className="space-y-2" data-testid="aws-vault-discovery-candidates">
{preview.candidates.map((candidate, index) => (
<AwsProviderVaultDiscoveryCandidateRow
key={`${candidate.displayName}-${index}`}
candidate={candidate}
onApply={() => onApply(candidate)}
/>
))}
</div>
</div>
) : null}
</div>
);
}
function AwsProviderVaultDiscoveryCandidateRow({
candidate,
onApply,
}: {
candidate: SecretProviderConfigDiscoveryCandidate;
onApply: () => void;
}) {
const fieldSummary = [
providerConfigValue(candidate.config, "region"),
providerConfigValue(candidate.config, "namespace"),
providerConfigValue(candidate.config, "secretNamePrefix"),
].filter(Boolean);
return (
<div className="rounded-md border border-border bg-background p-3">
<div className="flex items-start gap-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<p className="text-sm font-medium leading-snug">{candidate.displayName}</p>
<span className="text-xs text-muted-foreground">
{candidate.sampleCount} sample{candidate.sampleCount === 1 ? "" : "s"}
</span>
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
{fieldSummary.length > 0 ? fieldSummary.join(" / ") : "No stable namespace or prefix detected"}
</p>
{candidate.samples[0] ? (
<p className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{candidate.samples[0].name}
</p>
) : null}
</div>
<Button type="button" variant="ghost" size="sm" onClick={onApply}>
Use values
</Button>
</div>
{candidate.warnings.length > 0 ? (
<div className="mt-2 space-y-1 text-xs text-amber-700 dark:text-amber-300">
{candidate.warnings.map((warning) => (
<div key={warning} className="flex gap-2">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>{warning}</span>
</div>
))}
</div>
) : null}
</div>
);
}
function TextField({
label,
value,