[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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user