[codex] Add workspace diff viewer plugin (#6071)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies. > - Operators need to inspect what agents changed inside execution and project workspaces. > - The existing workspace detail views did not provide a first-party rich diff surface for staged, unstaged, head, renamed, binary, oversized, and untracked changes. > - The plugin system is the intended extension point for optional rich UI surfaces. > - This pull request adds a workspace diff plugin plus host services and shared contracts so Changes tabs can render workspace diffs through plugin slots. > - The diff-renderer dependency should stay owned by the plugin package rather than the core UI app. > - The dependency surface must stay aligned with repository PR policy, including intentionally omitting `pnpm-lock.yaml` from the PR. > - The benefit is a more reviewable workspace surface without hard-coding the renderer into every page. ## What Changed - Added `@paperclipai/plugin-workspace-diff`, including diff normalization, plugin manifest/worker/UI entrypoints, and focused plugin tests. - Kept `@pierre/diffs` scoped to `@paperclipai/plugin-workspace-diff`; removed the core UI lab diff-renderer surface and direct UI package dependency. - Added shared workspace diff types and validators, plus plugin SDK surface for workspace diff host services. - Added server workspace diff service support and route coverage for execution/project workspace diff flows. - Wired Execution Workspace and Project Workspace Changes tabs to load the diff plugin, including loading/error fallback behavior. - Added UI tests and fixtures for the Changes tabs and plugin bridge behavior. - Added the new plugin package manifest to the Docker deps stage so PR policy can validate dependency coverage. - Addressed review hardening around empty untracked patches, workspace path exposure, project workspace read capability checks, and default base refs. ## Verification - `pnpm --filter @paperclipai/plugin-workspace-diff test` - `pnpm exec vitest run packages/shared/src/validators/workspace-diff.test.ts server/src/__tests__/workspace-diff-service.test.ts ui/src/pages/ProjectWorkspaceDetail.test.tsx ui/src/pages/ExecutionWorkspaceDetail.test.tsx` - `pnpm exec vitest run ui/src/plugins/bridge.test.ts server/src/__tests__/workspace-runtime-routes-authz.test.ts` - `pnpm --filter @paperclipai/shared typecheck` - `pnpm --filter @paperclipai/plugin-workspace-diff typecheck` - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `node ./scripts/check-docker-deps-stage.mjs` - Browser screenshot captured from the local worktree dev server: https://files.catbox.moe/ofdpsp.png - Confirmed branch is rebased onto `public-gh/master`, `.github/workflows/pr.yml` is not included in the PR diff, `ui/package.json` is not included in the PR diff, and `pnpm-lock.yaml` is not included in the PR diff. ## Risks - Medium UI integration risk: the Changes tab depends on the plugin slot and host diff service path. - Medium dependency risk: this adds `@pierre/diffs` in the plugin package, but `pnpm-lock.yaml` is intentionally omitted per packaging instructions because repository automation manages lockfile updates. - Current CI blocker: downstream frozen installs fail until the repository policy path for new plugin package dependencies is chosen. - Diff rendering edge cases are covered for common working-tree and head diff states, but very large repositories may still expose performance limits. - No migrations are included. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5 class coding model, tool-enabled local execution environment. Exact context window was not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -26,4 +26,5 @@ describe("executionWorkspacesApi.listSummaries", () => {
|
||||
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -138,7 +138,7 @@ export interface AvailablePluginExample {
|
||||
displayName: string;
|
||||
description: string;
|
||||
localPath: string;
|
||||
tag: "example";
|
||||
tag: "example" | "first-party";
|
||||
}
|
||||
|
||||
export interface PluginLocalFolderProblem {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { Link } from "@/lib/router";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
interface MissingPluginTabPlaceholderProps {
|
||||
defaultTabHref: string;
|
||||
defaultTabLabel: string;
|
||||
}
|
||||
|
||||
export function MissingPluginTabPlaceholder({
|
||||
defaultTabHref,
|
||||
defaultTabLabel,
|
||||
}: MissingPluginTabPlaceholderProps) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p>Workspace plugin tab is not available.</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={defaultTabHref}>{defaultTabLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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 project’s 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");
|
||||
});
|
||||
});
|
||||
@@ -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 project’s 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 project’s 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
type PluginBridgeContextValue,
|
||||
} from "./bridge";
|
||||
import { initPluginBridge } from "./bridge-init";
|
||||
import { _createReactShimSourceForTests } from "./slots";
|
||||
|
||||
function clickEvent(
|
||||
overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {},
|
||||
@@ -304,3 +305,21 @@ describe("plugin SDK markdown component bridge", () => {
|
||||
}))).toContain("Run lint");
|
||||
});
|
||||
});
|
||||
|
||||
describe("plugin React shim", () => {
|
||||
it("re-exports every named export from the host React module", () => {
|
||||
const source = _createReactShimSourceForTests(React);
|
||||
|
||||
for (const name of Object.keys(React).sort()) {
|
||||
if (name === "default") continue;
|
||||
if (!/^[A-Za-z_$][\w$]*$/.test(name)) continue;
|
||||
expect(source).toContain(`export const ${name} = R.${name};`);
|
||||
}
|
||||
|
||||
expect(source).toContain("export default R;");
|
||||
expect(source).toContain("export const useInsertionEffect = R.useInsertionEffect;");
|
||||
expect(source).toContain("export const useId = R.useId;");
|
||||
expect(source).toContain("export const useSyncExternalStore = R.useSyncExternalStore;");
|
||||
expect(source).toContain("export const startTransition = R.startTransition;");
|
||||
});
|
||||
});
|
||||
|
||||
+21
-12
@@ -29,6 +29,7 @@ import {
|
||||
type ReactNode,
|
||||
type ComponentType,
|
||||
} from "react";
|
||||
import * as ReactModule from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import type {
|
||||
PluginLauncherDeclaration,
|
||||
@@ -244,24 +245,31 @@ function applyJsxRuntimeKey(
|
||||
return { ...(props ?? {}), key };
|
||||
}
|
||||
|
||||
function createReactShimSource(reactModule: object): string {
|
||||
const exportNames = Object.keys(reactModule)
|
||||
.filter((name) => name !== "default" && /^[A-Za-z_$][\w$]*$/.test(name))
|
||||
.sort();
|
||||
const namedExports = exportNames
|
||||
.map((name) => ` export const ${name} = R.${name};`)
|
||||
.join("\n");
|
||||
|
||||
return `
|
||||
const R = globalThis.__paperclipPluginBridge__?.react;
|
||||
if (!R) {
|
||||
throw new Error("Paperclip plugin React runtime is not initialized.");
|
||||
}
|
||||
export default R;
|
||||
${namedExports}
|
||||
`;
|
||||
}
|
||||
|
||||
function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | "react/jsx-runtime" | "sdk-ui"): string {
|
||||
if (shimBlobUrls[specifier]) return shimBlobUrls[specifier];
|
||||
|
||||
let source: string;
|
||||
switch (specifier) {
|
||||
case "react":
|
||||
source = `
|
||||
const R = globalThis.__paperclipPluginBridge__?.react;
|
||||
export default R;
|
||||
const { useState, useEffect, useCallback, useMemo, useRef, useContext,
|
||||
createContext, createElement, Fragment, Component, forwardRef,
|
||||
memo, lazy, Suspense, StrictMode, cloneElement, Children,
|
||||
isValidElement, createRef } = R;
|
||||
export { useState, useEffect, useCallback, useMemo, useRef, useContext,
|
||||
createContext, createElement, Fragment, Component, forwardRef,
|
||||
memo, lazy, Suspense, StrictMode, cloneElement, Children,
|
||||
isValidElement, createRef };
|
||||
`;
|
||||
source = createReactShimSource(ReactModule);
|
||||
break;
|
||||
case "react/jsx-runtime":
|
||||
source = `
|
||||
@@ -900,4 +908,5 @@ export function _resetPluginModuleLoader(): void {
|
||||
}
|
||||
|
||||
export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey;
|
||||
export const _createReactShimSourceForTests = createReactShimSource;
|
||||
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;
|
||||
|
||||
Reference in New Issue
Block a user