5071c4c776
## 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>
322 lines
11 KiB
TypeScript
322 lines
11 KiB
TypeScript
// @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",
|
|
]);
|
|
});
|
|
});
|