[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:
Dotta
2026-05-18 08:50:06 -05:00
committed by GitHub
parent 242a2c2f2b
commit 5071c4c776
48 changed files with 4119 additions and 71 deletions
+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