[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
+1
View File
@@ -36,6 +36,7 @@ COPY packages/plugins/sdk/package.json packages/plugins/sdk/
COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/ COPY --parents packages/plugins/sandbox-providers/./*/package.json packages/plugins/sandbox-providers/
COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/ COPY packages/plugins/paperclip-plugin-fake-sandbox/package.json packages/plugins/paperclip-plugin-fake-sandbox/
COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/ COPY packages/plugins/plugin-llm-wiki/package.json packages/plugins/plugin-llm-wiki/
COPY packages/plugins/plugin-workspace-diff/package.json packages/plugins/plugin-workspace-diff/
COPY patches/ patches/ COPY patches/ patches/
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
@@ -0,0 +1,72 @@
{
"name": "@paperclipai/plugin-workspace-diff",
"version": "0.1.0",
"description": "First-party execution workspace Changes tab powered by plugin-local workspace metadata",
"license": "MIT",
"homepage": "https://github.com/paperclipai/paperclip",
"bugs": {
"url": "https://github.com/paperclipai/paperclip/issues"
},
"repository": {
"type": "git",
"url": "https://github.com/paperclipai/paperclip",
"directory": "packages/plugins/plugin-workspace-diff"
},
"type": "module",
"exports": {
".": "./src/index.ts"
},
"publishConfig": {
"access": "public",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts"
},
"files": [
"dist"
],
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"keywords": [
"paperclip",
"plugin",
"workspace",
"diff"
],
"scripts": {
"postinstall": "node ../../../scripts/link-plugin-dev-sdk.mjs",
"prebuild": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps",
"build": "tsc && node ./scripts/build-ui.mjs",
"clean": "rm -rf dist",
"typecheck": "pnpm --filter @paperclipai/plugin-sdk ensure-build-deps && tsc --noEmit && tsc --noEmit -p tsconfig.test.json",
"test": "vitest run",
"prepack": "rm -f package.dev.json && cp package.json package.dev.json && node ../../../scripts/generate-plugin-package-json.mjs",
"postpack": "if [ -f package.dev.json ]; then mv package.dev.json package.json; fi"
},
"dependencies": {
"@paperclipai/plugin-sdk": "workspace:*",
"@pierre/diffs": "^1.1.22"
},
"devDependencies": {
"@types/node": "^24.6.0",
"@types/react": "^19.0.8",
"@types/react-dom": "^19.0.3",
"esbuild": "^0.27.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"typescript": "^5.7.3",
"vitest": "^3.0.5"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
}
@@ -0,0 +1,24 @@
import esbuild from "esbuild";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const packageRoot = path.resolve(__dirname, "..");
await esbuild.build({
entryPoints: [path.join(packageRoot, "src/ui/index.tsx")],
outfile: path.join(packageRoot, "dist/ui/index.js"),
bundle: true,
format: "esm",
platform: "browser",
target: ["es2022"],
sourcemap: true,
external: [
"react",
"react-dom",
"react/jsx-runtime",
"@paperclipai/plugin-sdk/ui",
],
logLevel: "info",
});
@@ -0,0 +1,144 @@
import { z } from "@paperclipai/plugin-sdk";
export const workspaceDiffViewSchema = z.enum(["working-tree", "head"]);
export const workspaceDiffFileStatusSchema = z.enum([
"added",
"modified",
"deleted",
"renamed",
"copied",
"type_changed",
"untracked",
"unknown",
]);
export const workspaceDiffPatchKindSchema = z.enum(["staged", "unstaged", "head", "untracked"]);
export const workspaceDiffWarningCodeSchema = z.enum([
"base_ref_missing",
"base_ref_invalid",
"binary_file",
"file_count_truncated",
"file_oversized",
"git_command_failed",
"missing_cwd",
"non_git_workspace",
"patch_truncated",
"path_filter_invalid",
"symlink_target_outside_workspace",
"workspace_path_invalid",
]);
const queryBooleanSchema = z
.union([z.boolean(), z.enum(["true", "false"])])
.transform((value) => value === true || value === "true");
function normalizePathQuery(value: unknown): string[] {
if (value == null) return [];
const values = Array.isArray(value) ? value : [value];
return values.flatMap((entry) => {
if (typeof entry !== "string") return [];
return entry
.split(",")
.map((filePath) => filePath.trim())
.filter(Boolean);
});
}
export const workspaceDiffQuerySchema = z
.object({
view: workspaceDiffViewSchema.optional().default("working-tree"),
baseRef: z.string().trim().min(1).max(240).optional().nullable(),
includeUntracked: queryBooleanSchema.optional().default(true),
path: z.union([z.string(), z.array(z.string())]).optional(),
paths: z.union([z.string(), z.array(z.string())]).optional(),
})
.passthrough()
.transform((value) => ({
view: value.view,
baseRef: value.baseRef?.trim() || null,
includeUntracked: value.includeUntracked,
paths: normalizePathQuery(value.paths ?? value.path),
}));
export const workspaceDiffWarningSchema = z.object({
code: workspaceDiffWarningCodeSchema,
message: z.string(),
path: z.string().nullable(),
}).strict();
export const workspaceDiffCapsSchema = z.object({
maxFiles: z.number().int().positive(),
maxFileBytes: z.number().int().positive(),
maxPatchBytes: z.number().int().positive(),
maxTotalPatchBytes: z.number().int().positive(),
}).strict();
export const workspaceDiffFilePatchSchema = z.object({
kind: workspaceDiffPatchKindSchema,
patch: z.string().nullable(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
binary: z.boolean(),
oversized: z.boolean(),
truncated: z.boolean(),
warnings: z.array(workspaceDiffWarningSchema),
}).strict();
export const workspaceDiffFileSchema = z.object({
path: z.string(),
oldPath: z.string().nullable(),
status: workspaceDiffFileStatusSchema,
staged: z.boolean(),
unstaged: z.boolean(),
untracked: z.boolean(),
binary: z.boolean(),
oversized: z.boolean(),
truncated: z.boolean(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
sizeBytes: z.number().int().nonnegative().nullable(),
patches: z.array(workspaceDiffFilePatchSchema),
warnings: z.array(workspaceDiffWarningSchema),
}).strict();
export const workspaceDiffStatsSchema = z.object({
fileCount: z.number().int().nonnegative(),
stagedFileCount: z.number().int().nonnegative(),
unstagedFileCount: z.number().int().nonnegative(),
untrackedFileCount: z.number().int().nonnegative(),
binaryFileCount: z.number().int().nonnegative(),
oversizedFileCount: z.number().int().nonnegative(),
truncatedFileCount: z.number().int().nonnegative(),
additions: z.number().int().nonnegative(),
deletions: z.number().int().nonnegative(),
}).strict();
export const workspaceDiffResponseSchema = z.object({
workspaceId: z.string(),
companyId: z.string(),
view: workspaceDiffViewSchema,
baseRef: z.string().nullable(),
defaultBaseRef: z.string().nullable(),
headSha: z.string().nullable(),
includeUntracked: z.boolean(),
paths: z.array(z.string()),
files: z.array(workspaceDiffFileSchema),
stats: workspaceDiffStatsSchema,
warnings: z.array(workspaceDiffWarningSchema),
caps: workspaceDiffCapsSchema,
truncated: z.boolean(),
}).strict();
export type WorkspaceDiffView = z.infer<typeof workspaceDiffViewSchema>;
export type WorkspaceDiffFileStatus = z.infer<typeof workspaceDiffFileStatusSchema>;
export type WorkspaceDiffPatchKind = z.infer<typeof workspaceDiffPatchKindSchema>;
export type WorkspaceDiffWarningCode = z.infer<typeof workspaceDiffWarningCodeSchema>;
export type WorkspaceDiffQueryOptions = z.infer<typeof workspaceDiffQuerySchema>;
export type WorkspaceDiffWarning = z.infer<typeof workspaceDiffWarningSchema>;
export type WorkspaceDiffCaps = z.infer<typeof workspaceDiffCapsSchema>;
export type WorkspaceDiffFilePatch = z.infer<typeof workspaceDiffFilePatchSchema>;
export type WorkspaceDiffFile = z.infer<typeof workspaceDiffFileSchema>;
export type WorkspaceDiffStats = z.infer<typeof workspaceDiffStatsSchema>;
export type WorkspaceDiffResponse = z.infer<typeof workspaceDiffResponseSchema>;
@@ -0,0 +1,143 @@
import type {
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
} from "./contracts.js";
export type DiffRenderMode = "unified" | "split";
export interface DiffPatchViewModel {
kind: WorkspaceDiffFilePatch["kind"];
patch: string | null;
lineCount: number;
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
}
export interface DiffFileViewModel {
path: string;
oldPath: string | null;
status: WorkspaceDiffFile["status"];
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
patchKinds: WorkspaceDiffFilePatch["kind"][];
patches: DiffPatchViewModel[];
patch: string | null;
lineCount: number;
longDiff: boolean;
}
export interface DiffSummaryViewModel {
changedLabel: string;
lineLabel: string;
warningCount: number;
truncated: boolean;
}
const STATUS_LABELS: Record<WorkspaceDiffFile["status"], string> = {
added: "Added",
modified: "Modified",
deleted: "Deleted",
renamed: "Renamed",
copied: "Copied",
type_changed: "Type changed",
untracked: "Untracked",
unknown: "Changed",
};
export const LONG_DIFF_LINE_THRESHOLD = 400;
export function statusLabel(status: WorkspaceDiffFile["status"]) {
return STATUS_LABELS[status] ?? "Changed";
}
export function fileName(filePath: string) {
return filePath.split("/").filter(Boolean).pop() ?? filePath;
}
export function buildFilePatches(file: WorkspaceDiffFile): DiffPatchViewModel[] {
return file.patches.map((patch) => {
const textPatch = patch.patch?.trimEnd() ?? null;
const lineCount = textPatch ? textPatch.split("\n").length : 0;
return {
kind: patch.kind,
patch: textPatch && textPatch.length > 0 ? textPatch : null,
lineCount,
additions: patch.additions,
deletions: patch.deletions,
binary: patch.binary,
oversized: patch.oversized,
truncated: patch.truncated,
warnings: patch.warnings,
};
});
}
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
return buildFilePatches(file).find((patch) => patch.patch)?.patch ?? null;
}
export function isLongDiffFile(file: Pick<DiffFileViewModel, "lineCount">) {
return file.lineCount > LONG_DIFF_LINE_THRESHOLD;
}
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
return (diff?.files ?? []).map((file) => {
const patches = buildFilePatches(file);
const lineCount = patches.reduce((count, patch) => count + patch.lineCount, 0);
return {
path: file.path,
oldPath: file.oldPath,
status: file.status,
additions: file.additions,
deletions: file.deletions,
binary: file.binary,
oversized: file.oversized,
truncated: file.truncated,
warnings: file.warnings,
patchKinds: file.patches.map((patch) => patch.kind),
patches,
patch: patches.find((patch) => patch.patch)?.patch ?? null,
lineCount,
longDiff: isLongDiffFile({ lineCount }),
};
});
}
export function diffSummary(diff: WorkspaceDiffResponse | null | undefined): DiffSummaryViewModel {
const stats = diff?.stats;
const fileCount = stats?.fileCount ?? 0;
const additions = stats?.additions ?? 0;
const deletions = stats?.deletions ?? 0;
const warningCount = diff?.warnings.length ?? 0;
return {
changedLabel: `${fileCount} ${fileCount === 1 ? "file" : "files"}`,
lineLabel: `+${additions} / -${deletions}`,
warningCount,
truncated: Boolean(diff?.truncated),
};
}
export function nextExpandedFileSet(
current: ReadonlySet<string>,
filePath: string,
): Set<string> {
const next = new Set(current);
if (next.has(filePath)) next.delete(filePath);
else next.add(filePath);
return next;
}
export function initialExpandedFileSet(files: readonly DiffFileViewModel[]): Set<string> {
return new Set(files.filter((file) => !file.longDiff).map((file) => file.path));
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";
@@ -0,0 +1,37 @@
import type { PaperclipPluginManifestV1 } from "@paperclipai/plugin-sdk";
const PLUGIN_ID = "paperclip.workspace-diff";
const CHANGES_TAB_SLOT_ID = "workspace-changes-tab";
const manifest: PaperclipPluginManifestV1 = {
id: PLUGIN_ID,
apiVersion: 1,
version: "0.1.0",
displayName: "Workspace Changes",
description: "Adds a Changes tab to execution and project workspaces using plugin-local Git diff computation and @pierre/diffs.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"ui.detailTab.register",
"execution.workspaces.read",
"project.workspaces.read",
],
entrypoints: {
worker: "./dist/worker.js",
ui: "./dist/ui",
},
ui: {
slots: [
{
type: "detailTab",
id: CHANGES_TAB_SLOT_ID,
displayName: "Changes",
exportName: "ChangesTab",
entityTypes: ["execution_workspace", "project_workspace"],
order: 25,
},
],
},
};
export default manifest;
@@ -0,0 +1,617 @@
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import { DIFFS_TAG_NAME, getSingularPatch } from "@pierre/diffs";
import type { PatchDiffProps } from "@pierre/diffs/react";
import { useFileDiffInstance } from "@pierre/diffs/react";
import { createElement, type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
diffSummary,
fileName,
initialExpandedFileSet,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
type DiffFileViewModel,
type DiffPatchViewModel,
type DiffRenderMode,
} from "../diff-model.js";
import type { WorkspaceDiffResponse } from "../contracts.js";
type WorkspaceDiffData = WorkspaceDiffResponse;
type WorkspacePatchDiffOptions = PatchDiffProps<undefined>["options"];
type DiffViewMode = "working-tree" | "head";
type LucideIconProps = { size?: number };
function makeLucideIcon(paths: ReactNode) {
return function LucideIcon({ size = 16 }: LucideIconProps) {
return (
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
style={{ width: size, height: size, display: "block" }}
>
{paths}
</svg>
);
};
}
// Plugin bundles cannot import host-only lucide-react; this mirrors lucide RefreshCw.
const RefreshCwIcon = makeLucideIcon(
<>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M8 16H3v5" />
</>,
);
function readInitialView(): DiffViewMode {
if (typeof window === "undefined") return "working-tree";
return new URLSearchParams(window.location.search).get("diffView") === "head" ? "head" : "working-tree";
}
function readInitialBaseRef() {
if (typeof window === "undefined") return "";
return new URLSearchParams(window.location.search).get("baseRef") ?? "";
}
function buttonClass(active = false) {
return [
"inline-flex h-8 items-center justify-center rounded-md border px-2.5 text-xs font-medium transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function iconButtonClass(active = false) {
return [
"inline-flex h-7 w-7 items-center justify-center rounded-md border text-xs transition-colors",
active
? "border-foreground/20 bg-foreground text-background"
: "border-border bg-background text-muted-foreground hover:text-foreground",
].join(" ");
}
function warningText(file: DiffFileViewModel) {
if (file.binary) return "Binary file";
if (file.oversized) return "Too large to render";
if (file.truncated) return "Patch truncated";
if (file.warnings.length > 0) return file.warnings[0]?.message ?? "Diff warning";
if (file.patches.every((patch) => !patch.patch)) return "No text patch";
return null;
}
const PATCH_KIND_LABELS: Record<DiffPatchViewModel["kind"], string> = {
staged: "Staged",
unstaged: "Unstaged",
head: "Head",
untracked: "Untracked",
};
function patchKindLabel(kind: DiffPatchViewModel["kind"]) {
return PATCH_KIND_LABELS[kind] ?? "Patch";
}
function patchWarningText(patch: DiffPatchViewModel) {
if (patch.binary) return "Binary file";
if (patch.oversized) return "Too large to render";
if (patch.truncated) return "Patch truncated";
if (patch.warnings.length > 0) return patch.warnings[0]?.message ?? "Diff warning";
if (!patch.patch) return "No text patch";
return null;
}
function FileRow({
file,
active,
expanded,
onSelect,
onToggle,
onCopy,
}: {
file: DiffFileViewModel;
active: boolean;
expanded: boolean;
onSelect: () => void;
onToggle: () => void;
onCopy: () => void;
}) {
const warning = warningText(file);
const expandLabel = expanded ? "Collapse file" : "Expand file";
const fileAriaLabel = expanded ? `Collapse ${file.path}` : `Expand ${file.path}`;
return (
<div
className={[
"group border-b border-border/70 px-3 py-2 last:border-b-0",
active ? "bg-accent/60" : "bg-background hover:bg-muted/45",
].join(" ")}
>
<div key="main" className="flex min-w-0 items-start gap-2">
<button
key="toggle"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggle}
title={expandLabel}
aria-label={fileAriaLabel}
>
{expanded ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 flex-1 text-left"
onClick={onSelect}
>
<div key="name" className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
<div key="path" className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
</button>
<button
key="copy"
type="button"
className="text-muted-foreground opacity-0 transition-opacity hover:text-foreground group-hover:opacity-100"
onClick={onCopy}
title="Copy path"
aria-label={`Copy ${file.path}`}
>
</button>
</div>
<div key="meta" className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
<span key="status">{statusLabel(file.status)}</span>
<span key="additions" className="font-mono text-emerald-700 dark:text-emerald-300">{`+${file.additions}`}</span>
<span key="deletions" className="font-mono text-red-700 dark:text-red-300">{`-${file.deletions}`}</span>
{warning ? <span key="warning" className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
</div>
</div>
);
}
// The upstream React wrapper emits React 19 key warnings for its internal slot array.
// This mounts the same Diffs custom element through the exported imperative hook.
function WorkspacePatchDiff({
patch,
options,
}: {
patch: string;
options: WorkspacePatchDiffOptions;
}) {
const fileDiff = useMemo(() => getSingularPatch(patch), [patch]);
const { ref } = useFileDiffInstance({
fileDiff,
options,
metrics: undefined,
lineAnnotations: undefined,
selectedLines: undefined,
prerenderedHTML: undefined,
hasGutterRenderUtility: false,
hasCustomHeader: false,
disableWorkerPool: false,
});
return createElement(DIFFS_TAG_NAME, { ref });
}
function EmptyState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center">
<div className="text-sm font-medium text-foreground">No workspace changes</div>
<div className="mt-1 text-sm text-muted-foreground">
The workspace matches its current comparison target.
</div>
</div>
);
}
function LoadingState() {
return (
<div className="border border-dashed border-border bg-background px-4 py-8 text-center text-sm text-muted-foreground">
Loading workspace changes
</div>
);
}
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return (
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm" role="alert">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">Unable to load workspace changes.</div>
<div className="mt-1 text-muted-foreground">
Retry the request or open the details below for the technical error.
</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onRetry}
aria-label="Retry loading workspace changes"
>
Retry
</button>
</div>
<details className="mt-3">
<summary className="cursor-pointer text-xs font-medium text-muted-foreground hover:text-foreground">
Troubleshooting details
</summary>
<pre className="mt-2 max-h-40 overflow-auto whitespace-pre-wrap break-words border border-border bg-background px-3 py-2 font-mono text-xs text-muted-foreground">
{message || "No error message was provided."}
</pre>
</details>
</div>
);
}
function FileDiffPanel({
file,
mode,
}: {
file: DiffFileViewModel;
mode: DiffRenderMode;
}) {
const warning = warningText(file);
if (warning) {
return (
<div className="border border-dashed border-border bg-background px-4 py-6 text-sm text-muted-foreground">
{warning ?? "No renderable patch is available for this file."}
</div>
);
}
return (
<div className="space-y-3">
{file.patches.map((patch, index) => {
const patchWarning = patchWarningText(patch);
return (
<div key={`${patch.kind}:${index}`} className="overflow-hidden border border-border bg-background">
{file.patches.length > 1 ? (
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-3 py-2 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{patchKindLabel(patch.kind)}</span>
<span className="font-mono text-emerald-700 dark:text-emerald-300">{`+${patch.additions}`}</span>
<span className="font-mono text-red-700 dark:text-red-300">{`-${patch.deletions}`}</span>
</div>
) : null}
{patchWarning || !patch.patch ? (
<div className="px-4 py-6 text-sm text-muted-foreground">
{patchWarning ?? "No renderable patch is available for this file."}
</div>
) : (
<WorkspacePatchDiff
patch={patch.patch}
options={{
diffStyle: mode,
overflow: "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
)}
</div>
);
})}
</div>
);
}
function CollapsedFilePanel({
file,
onExpand,
}: {
file: DiffFileViewModel;
onExpand: () => void;
}) {
const title = file.longDiff ? "Large diff folded" : "Diff folded";
const details = file.lineCount > 0
? `${file.lineCount.toLocaleString()} lines`
: statusLabel(file.status);
return (
<div className="border border-dashed border-border bg-background px-4 py-5 text-sm text-muted-foreground">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="min-w-0">
<div className="font-medium text-foreground">{title}</div>
<div className="mt-1 font-mono text-xs">{details}</div>
</div>
<button
type="button"
className={buttonClass(false)}
onClick={onExpand}
aria-label={`Show diff for ${file.path}`}
>
Show file
</button>
</div>
</div>
);
}
export function ChangesTab({ context }: PluginDetailTabProps) {
const toast = usePluginToast();
const [mode, setMode] = useState<DiffRenderMode>("split");
const [view, setView] = useState<DiffViewMode>(() => readInitialView());
const [baseRef, setBaseRef] = useState(() => readInitialBaseRef());
const baseRefTouchedRef = useRef(Boolean(baseRef.trim()));
const [includeUntracked, setIncludeUntracked] = useState(false);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const fileSectionRefs = useRef(new Map<string, HTMLElement>());
const diffScrollRef = useRef<HTMLElement | null>(null);
const scrollSyncFrameRef = useRef<number | null>(null);
const params = useMemo(() => ({
workspaceId: context.entityId,
companyId: context.companyId ?? "",
projectId: context.projectId ?? "",
entityType: context.entityType,
view,
baseRef: baseRef.trim() || null,
includeUntracked,
}), [baseRef, context.companyId, context.entityId, context.entityType, context.projectId, includeUntracked, view]);
const { data, loading, error, refresh } = usePluginData<WorkspaceDiffData>("workspace-diff", params);
const files = useMemo(() => toFileViewModels(data), [data]);
const summary = useMemo(() => diffSummary(data), [data]);
const selectedFile = files.find((file) => file.path === selectedPath) ?? files[0] ?? null;
const compareLabel = `${data?.baseRef ? `base ${data.baseRef}` : "working tree"}${data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}`;
const setFileSectionRef = useCallback((filePath: string) => (node: HTMLElement | null) => {
if (node) fileSectionRefs.current.set(filePath, node);
else fileSectionRefs.current.delete(filePath);
}, []);
const selectFile = useCallback((filePath: string) => {
setSelectedPath(filePath);
window.requestAnimationFrame(() => {
fileSectionRefs.current.get(filePath)?.scrollIntoView({
block: "start",
behavior: "smooth",
});
});
}, []);
const syncSelectedPathFromScroll = useCallback(() => {
const container = diffScrollRef.current;
if (!container || files.length === 0) return;
const containerTop = container.getBoundingClientRect().top;
let nextPath = files[0]?.path ?? null;
for (const file of files) {
const section = fileSectionRefs.current.get(file.path);
if (!section) continue;
const offsetFromScrollTop = section.getBoundingClientRect().top - containerTop;
if (offsetFromScrollTop <= 48) {
nextPath = file.path;
} else {
break;
}
}
if (nextPath) {
setSelectedPath((current) => current === nextPath ? current : nextPath);
}
}, [files]);
const handleDiffScroll = useCallback(() => {
if (scrollSyncFrameRef.current !== null) return;
scrollSyncFrameRef.current = window.requestAnimationFrame(() => {
scrollSyncFrameRef.current = null;
syncSelectedPathFromScroll();
});
}, [syncSelectedPathFromScroll]);
useEffect(() => {
const defaultBaseRef = data?.defaultBaseRef?.trim();
if (!defaultBaseRef || baseRef.trim() || baseRefTouchedRef.current) return;
setBaseRef(defaultBaseRef);
}, [baseRef, data?.defaultBaseRef]);
useEffect(() => {
if (files.length === 0) {
setExpandedFiles(new Set());
setSelectedPath(null);
return;
}
setExpandedFiles(initialExpandedFileSet(files));
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
}, [files]);
useEffect(() => {
return () => {
if (scrollSyncFrameRef.current !== null) {
window.cancelAnimationFrame(scrollSyncFrameRef.current);
}
};
}, []);
const copyPath = async (filePath: string) => {
try {
await navigator.clipboard.writeText(filePath);
toast({ title: "Path copied", body: filePath });
} catch {
toast({ title: "Copy failed", body: filePath, tone: "error" });
}
};
return (
<div className="space-y-3">
<div key="toolbar" className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
<div key="summary" className="min-w-0">
<div key="summary-line" className="flex flex-wrap items-center gap-2 text-sm">
<span key="changed" className="font-medium text-foreground">{summary.changedLabel}</span>
<span key="lines" className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
{summary.truncated ? (
<span key="truncated" className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
) : null}
{summary.warningCount > 0 ? (
<span key="warnings" className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
) : null}
</div>
<div key="compare" className="mt-1 truncate font-mono text-xs text-muted-foreground">
{compareLabel}
</div>
</div>
<div key="actions" className="flex flex-wrap items-center gap-2">
<div key="layout" className="inline-flex gap-1" aria-label="Diff layout">
<button key="split" type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
Split
</button>
<button key="unified" type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
Unified
</button>
</div>
<div key="view" className="inline-flex gap-1" aria-label="Diff comparison">
<button key="working-tree" type="button" className={buttonClass(view === "working-tree")} onClick={() => setView("working-tree")}>
Working tree
</button>
<button key="head" type="button" className={buttonClass(view === "head")} onClick={() => setView("head")}>
Against ref
</button>
</div>
{view === "head" ? (
<input
key="base-ref"
className="h-8 w-40 rounded-md border border-border bg-background px-2.5 font-mono text-xs outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40"
value={baseRef}
onChange={(event) => {
baseRefTouchedRef.current = true;
setBaseRef(event.target.value);
}}
placeholder="origin/master"
aria-label="Base ref"
/>
) : null}
{view === "working-tree" ? (
<button
key="untracked"
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
{includeUntracked ? "Untracked shown" : "Show untracked"}
</button>
) : null}
<button
key="refresh"
type="button"
className={iconButtonClass(false)}
onClick={() => refresh()}
title="Refresh changes"
aria-label="Refresh changes"
>
<RefreshCwIcon />
</button>
</div>
</div>
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error.message} onRetry={refresh} />
) : files.length === 0 ? (
<EmptyState />
) : (
<div key="content" className="grid gap-3 lg:h-[70vh] lg:min-h-[560px] lg:max-h-[820px] lg:grid-cols-[280px_minmax(0,1fr)]">
<aside key="files" className="flex min-w-0 flex-col border border-border bg-background lg:h-full lg:overflow-hidden">
<div key="heading" className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Files
</div>
<div key="list" className="max-h-[70vh] overflow-auto lg:max-h-none lg:flex-1">
{files.map((file, index) => (
<FileRow
key={`${file.path}:${index}`}
file={file}
active={file.path === selectedFile?.path}
expanded={expandedFiles.has(file.path)}
onSelect={() => selectFile(file.path)}
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
onCopy={() => void copyPath(file.path)}
/>
))}
</div>
</aside>
<main
key="diffs"
ref={diffScrollRef}
className="max-h-[70vh] min-w-0 space-y-3 overflow-auto lg:h-full lg:max-h-none lg:pr-1"
onScroll={handleDiffScroll}
>
{files
.map((file, index) => (
<section
key={`${file.path}:${index}`}
ref={setFileSectionRef(file.path)}
className={file.path === selectedFile?.path ? "scroll-mt-2" : undefined}
>
<div key="header" className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
<div key="left" className="flex min-w-0 items-start gap-2">
<button
key="collapse"
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
title={expandedFiles.has(file.path) ? "Collapse file" : "Expand file"}
aria-label={expandedFiles.has(file.path) ? `Collapse ${file.path}` : `Expand ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
{expandedFiles.has(file.path) ? "" : "+"}
</button>
<button
key="select"
type="button"
className="min-w-0 text-left"
onClick={() => selectFile(file.path)}
>
<div key="path" className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div key="old-path" className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
</div>
<div key="actions" className="flex shrink-0 items-center gap-1">
<button
key="copy"
type="button"
className={iconButtonClass(false)}
title="Copy path"
aria-label={`Copy ${file.path}`}
onClick={() => void copyPath(file.path)}
>
</button>
</div>
</div>
{expandedFiles.has(file.path) ? (
<FileDiffPanel key="diff" file={file} mode={mode} />
) : (
<CollapsedFilePanel
key="collapsed"
file={file}
onExpand={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
/>
)}
</section>
))}
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,91 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import { workspaceDiffQuerySchema } from "./contracts.js";
import { workspaceDiffService } from "./workspace-diff.js";
const PLUGIN_NAME = "workspace-diff";
function readString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readOptionalString(value: unknown): string | null {
const trimmed = readString(value);
return trimmed || null;
}
export function resolveDefaultBaseRef(input: {
workspaceBaseRef?: unknown;
projectWorkspaceDefaultRef?: unknown;
projectWorkspaceRepoRef?: unknown;
}): string | null {
return readOptionalString(input.workspaceBaseRef)
?? readOptionalString(input.projectWorkspaceDefaultRef)
?? readOptionalString(input.projectWorkspaceRepoRef);
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
const workspaceDiff = workspaceDiffService();
ctx.data.register("workspace-diff", async (params: Record<string, unknown>) => {
const workspaceId = readString(params.workspaceId);
const companyId = readString(params.companyId);
if (!workspaceId || !companyId) {
throw new Error("workspaceId and companyId are required");
}
if (params.entityType === "project_workspace") {
const projectId = readString(params.projectId);
if (!projectId) {
throw new Error("projectId is required for project workspace diffs");
}
const workspaces = await ctx.projects.listWorkspaces(projectId, companyId);
const workspace = workspaces.find((candidate) => candidate.id === workspaceId);
if (!workspace) {
throw new Error("Workspace not found");
}
return workspaceDiff.getDiff({
id: workspace.id,
companyId,
cwd: workspace.path,
baseRef: resolveDefaultBaseRef({
projectWorkspaceDefaultRef: workspace.defaultRef,
projectWorkspaceRepoRef: workspace.repoRef,
}),
}, workspaceDiffQuerySchema.parse(params));
}
const workspace = await ctx.executionWorkspaces.get(workspaceId, companyId);
if (!workspace) {
throw new Error("Workspace not found");
}
let projectWorkspaceDefaultBaseRef: string | null = null;
if (!readOptionalString(workspace.baseRef) && workspace.projectWorkspaceId) {
const workspaces = await ctx.projects.listWorkspaces(workspace.projectId, companyId);
const projectWorkspace = workspaces.find((candidate) => candidate.id === workspace.projectWorkspaceId);
projectWorkspaceDefaultBaseRef = projectWorkspace
? resolveDefaultBaseRef({
projectWorkspaceDefaultRef: projectWorkspace.defaultRef,
projectWorkspaceRepoRef: projectWorkspace.repoRef,
})
: null;
}
return workspaceDiff.getDiff({
...workspace,
baseRef: resolveDefaultBaseRef({
workspaceBaseRef: workspace.baseRef,
projectWorkspaceDefaultRef: projectWorkspaceDefaultBaseRef,
}),
}, workspaceDiffQuerySchema.parse(params));
});
},
async onHealth() {
return { status: "ok", message: `${PLUGIN_NAME} ready` };
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,787 @@
import { execFile } from "node:child_process";
import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
import type {
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
} from "./contracts.js";
const execFileAsync = promisify(execFile);
export const WORKSPACE_DIFF_CAPS: WorkspaceDiffCaps = {
maxFiles: 200,
maxFileBytes: 512 * 1024,
maxPatchBytes: 256 * 1024,
maxTotalPatchBytes: 1024 * 1024,
};
const GIT_TIMEOUT_MS = 10_000;
const GIT_LIST_MAX_BUFFER = 2 * 1024 * 1024;
const OPEN_NOFOLLOW = fsConstants.O_NOFOLLOW ?? 0;
interface GitStatusEntry {
status: WorkspaceDiffFileStatus;
path: string;
oldPath: string | null;
}
type DiffScope = "staged" | "unstaged" | "head";
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
patchScopes: DiffScope[];
}
interface PatchBudget {
totalPatchBytes: number;
}
type WorkspaceDiffTarget = Pick<PluginExecutionWorkspaceMetadata, "id" | "companyId" | "cwd" | "baseRef">;
function warning(code: WorkspaceDiffWarningCode, message: string, filePath: string | null = null): WorkspaceDiffWarning {
return { code, message, path: filePath };
}
function workspaceDiffError(code: WorkspaceDiffWarningCode, message: string, details: Record<string, unknown> = {}) {
const error = new Error(message);
Object.assign(error, { code, status: 422, details: { code, ...details } });
return error;
}
function toErrorMessage(error: unknown) {
if (error instanceof Error) return error.message;
return String(error);
}
async function runGit(cwd: string, args: string[], maxBuffer = GIT_LIST_MAX_BUFFER) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer,
});
} catch (error) {
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
const message = stderr || toErrorMessage(error);
throw workspaceDiffError("git_command_failed", message, { args });
}
}
async function realDirectory(value: string, code: WorkspaceDiffWarningCode) {
if (!path.isAbsolute(value)) {
throw workspaceDiffError(code, "Execution workspace path must be absolute", { cwd: value });
}
let stat: Awaited<ReturnType<typeof fs.stat>>;
try {
stat = await fs.stat(value);
} catch {
throw workspaceDiffError(code, "Execution workspace path does not exist", { cwd: value });
}
if (!stat.isDirectory()) {
throw workspaceDiffError(code, "Execution workspace path is not a directory", { cwd: value });
}
return await fs.realpath(value);
}
function isWithinDirectory(childPath: string, parentPath: string) {
const relative = path.relative(parentPath, childPath);
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
async function resolveWorkspacePaths(workspace: WorkspaceDiffTarget) {
if (!workspace.cwd?.trim()) {
throw workspaceDiffError(
"missing_cwd",
"Execution workspace needs a local path before Paperclip can inspect diffs",
{ workspaceId: workspace.id },
);
}
const cwd = await realDirectory(workspace.cwd.trim(), "workspace_path_invalid");
let repoRoot: string;
try {
repoRoot = (await runGit(cwd, ["rev-parse", "--show-toplevel"])).stdout.trim();
} catch {
throw workspaceDiffError(
"non_git_workspace",
"Execution workspace path is not inside a git repository",
{ workspaceId: workspace.id, cwd },
);
}
const repoRootReal = await realDirectory(repoRoot, "non_git_workspace");
if (!isWithinDirectory(cwd, repoRootReal)) {
throw workspaceDiffError(
"workspace_path_invalid",
"Execution workspace path resolved outside its git repository",
{ workspaceId: workspace.id, cwd, repoRoot: repoRootReal },
);
}
return { cwd, repoRoot: repoRootReal };
}
function normalizePathFilter(rawPath: string) {
const value = rawPath.trim().replaceAll("\\", "/");
if (!value || value === ".") return null;
if (value.includes("\0") || value.startsWith("/")) {
throw workspaceDiffError("path_filter_invalid", "Path filters must be relative workspace paths", { path: rawPath });
}
const normalized = path.posix.normalize(value);
if (
normalized === "." ||
normalized === ".." ||
normalized.startsWith("../") ||
normalized.includes("/../")
) {
throw workspaceDiffError(
"path_filter_invalid",
"Path filters must not contain traversal segments",
{ path: rawPath },
);
}
return normalized;
}
function normalizePathFilters(paths: string[]) {
return Array.from(new Set(paths.map(normalizePathFilter).filter((value): value is string => Boolean(value))));
}
function statusFromGitStatus(status: string): WorkspaceDiffFileStatus {
if (status.startsWith("R")) return "renamed";
if (status.startsWith("C")) return "copied";
switch (status[0]) {
case "A":
return "added";
case "D":
return "deleted";
case "M":
return "modified";
case "T":
return "type_changed";
default:
return "unknown";
}
}
function parseNameStatus(output: string): GitStatusEntry[] {
const tokens = output.split("\0").filter(Boolean);
const entries: GitStatusEntry[] = [];
let index = 0;
while (index < tokens.length) {
const statusCode = tokens[index++] ?? "";
if (!statusCode) continue;
if (statusCode.startsWith("R") || statusCode.startsWith("C")) {
const oldPath = tokens[index++] ?? "";
const newPath = tokens[index++] ?? "";
if (newPath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: newPath,
oldPath: oldPath || null,
});
}
continue;
}
const filePath = tokens[index++] ?? "";
if (filePath) {
entries.push({
status: statusFromGitStatus(statusCode),
path: filePath,
oldPath: null,
});
}
}
return entries;
}
async function readDiffNameStatus(cwd: string, scopeArgs: string[], paths: string[]) {
const result = await runGit(cwd, [
"diff",
"--name-status",
"-z",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
...paths,
]);
return parseNameStatus(result.stdout);
}
async function readUntrackedPaths(cwd: string, paths: string[]) {
const result = await runGit(cwd, ["ls-files", "--others", "--exclude-standard", "-z", "--", ...paths]);
return result.stdout.split("\0").filter(Boolean);
}
function ensureFile(
files: Map<string, MutableWorkspaceDiffFile>,
filePath: string,
status: WorkspaceDiffFileStatus,
oldPath: string | null,
) {
const existing = files.get(filePath);
if (existing) {
if (existing.status === "unknown" || status === "renamed" || status === "copied") {
existing.status = status;
}
if (!existing.oldPath && oldPath) existing.oldPath = oldPath;
return existing;
}
const file: MutableWorkspaceDiffFile = {
path: filePath,
oldPath,
status,
staged: false,
unstaged: false,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 0,
deletions: 0,
sizeBytes: null,
patches: [],
warnings: [],
patchScopes: [],
};
files.set(filePath, file);
return file;
}
function addStatusEntries(
files: Map<string, MutableWorkspaceDiffFile>,
entries: GitStatusEntry[],
scope: DiffScope,
) {
for (const entry of entries) {
const file = ensureFile(files, entry.path, entry.status, entry.oldPath);
if (scope === "staged") file.staged = true;
else if (scope === "unstaged") file.unstaged = true;
if (!file.patchScopes.includes(scope)) file.patchScopes.push(scope);
}
}
function parseNumstat(output: string) {
const line = output.split(/\r?\n/).find(Boolean);
if (!line) return { additions: 0, deletions: 0, binary: false };
const [additionsRaw, deletionsRaw] = line.split(/\t/);
if (additionsRaw === "-" || deletionsRaw === "-") {
return { additions: 0, deletions: 0, binary: true };
}
return {
additions: Number.parseInt(additionsRaw ?? "0", 10) || 0,
deletions: Number.parseInt(deletionsRaw ?? "0", 10) || 0,
binary: false,
};
}
async function readNumstat(cwd: string, scopeArgs: string[], filePath: string) {
const result = await runGit(cwd, [
"diff",
"--numstat",
"--no-ext-diff",
"--find-renames",
...scopeArgs,
"--",
filePath,
], 128 * 1024);
return parseNumstat(result.stdout);
}
async function statWorkspaceFile(repoRoot: string, filePath: string) {
const resolved = await resolveWorkspaceFilePath(repoRoot, filePath);
if (resolved.status !== "ok") return null;
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return null;
}
try {
const stat = await handle.stat();
return stat.isFile() ? stat.size : null;
} catch {
return null;
} finally {
await handle.close();
}
}
async function resolveWorkspaceFilePath(repoRoot: string, filePath: string): Promise<
| { status: "ok"; realPath: string }
| { status: "missing" }
| { status: "outside_workspace" }
> {
const target = path.resolve(repoRoot, filePath);
if (!isWithinDirectory(target, repoRoot)) return { status: "outside_workspace" };
try {
const realPath = await fs.realpath(target);
if (!isWithinDirectory(realPath, repoRoot)) return { status: "outside_workspace" };
return { status: "ok", realPath };
} catch {
return { status: "missing" };
}
}
function isMaxBufferError(error: unknown) {
return typeof error === "object"
&& error !== null
&& "code" in error
&& (error as { code?: unknown }).code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
}
async function readPatchOutput(cwd: string, args: string[]) {
try {
return await execFileAsync("git", ["-C", cwd, ...args], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: WORKSPACE_DIFF_CAPS.maxPatchBytes + 64 * 1024,
});
} catch (error) {
if (isMaxBufferError(error)) {
return null;
}
const stderr = typeof (error as { stderr?: unknown }).stderr === "string"
? String((error as { stderr?: unknown }).stderr).trim()
: "";
throw workspaceDiffError("git_command_failed", stderr || toErrorMessage(error), { args });
}
}
function reservePatchBytes(
patch: string,
budget: PatchBudget,
filePath: string,
warnings: WorkspaceDiffWarning[],
) {
const patchBytes = Buffer.byteLength(patch, "utf8");
if (patchBytes > WORKSPACE_DIFF_CAPS.maxPatchBytes) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", filePath));
return null;
}
if (budget.totalPatchBytes + patchBytes > WORKSPACE_DIFF_CAPS.maxTotalPatchBytes) {
warnings.push(warning("patch_truncated", "Workspace diff exceeded the total patch cap.", filePath));
return null;
}
budget.totalPatchBytes += patchBytes;
return patch;
}
async function buildTrackedPatch(input: {
cwd: string;
repoRoot: string;
filePath: string;
kind: WorkspaceDiffPatchKind;
scopeArgs: string[];
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const numstat = await readNumstat(input.cwd, input.scopeArgs, input.filePath);
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
if (numstat.binary) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
if (sizeBytes !== null && sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "File is too large to include a text patch.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
const patchOutput = await readPatchOutput(input.cwd, [
"diff",
"--no-ext-diff",
"--find-renames",
"--unified=3",
...input.scopeArgs,
"--",
input.filePath,
]);
if (!patchOutput) {
warnings.push(warning("patch_truncated", "File patch exceeded the per-file diff cap.", input.filePath));
return {
kind: input.kind,
patch: null,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: true,
warnings,
};
}
const patch = reservePatchBytes(patchOutput.stdout, input.budget, input.filePath, warnings);
return {
kind: input.kind,
patch,
additions: numstat.additions,
deletions: numstat.deletions,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function isProbablyBinary(buffer: Buffer) {
return buffer.subarray(0, Math.min(buffer.length, 8_000)).includes(0);
}
function countAddedLines(content: string) {
if (content.length === 0) return 0;
return content.endsWith("\n") ? content.split("\n").length - 1 : content.split("\n").length;
}
function buildUntrackedPatch(filePath: string, content: string) {
const lines = content.length === 0 ? [] : content.split("\n");
if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop();
const lineCount = countAddedLines(content);
const header = [
`diff --git a/${filePath} b/${filePath}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${filePath}`,
];
if (lineCount === 0) return `${header.join("\n")}\n`;
const hunkLines = lines.map((line) => `+${line}`).join("\n");
return [...header, `@@ -0,0 +1,${lineCount} @@`, hunkLines, ""].join("\n");
}
async function buildUntrackedFilePatch(input: {
repoRoot: string;
filePath: string;
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const resolved = await resolveWorkspaceFilePath(input.repoRoot, input.filePath);
if (resolved.status === "outside_workspace") {
warnings.push(warning(
"symlink_target_outside_workspace",
"Untracked file resolves outside the workspace and is summarized without reading target bytes.",
input.filePath,
));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (resolved.status === "missing") {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let handle: Awaited<ReturnType<typeof fs.open>>;
try {
handle = await fs.open(resolved.realPath, fsConstants.O_RDONLY | OPEN_NOFOLLOW);
} catch {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
let sizeBytes: number;
let buffer: Buffer | null = null;
try {
const stat = await handle.stat();
if (!stat.isFile()) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
sizeBytes = stat.size;
if (sizeBytes <= WORKSPACE_DIFF_CAPS.maxFileBytes) {
buffer = await handle.readFile();
}
} finally {
await handle.close();
}
if (sizeBytes > WORKSPACE_DIFF_CAPS.maxFileBytes) {
warnings.push(warning("file_oversized", "Untracked file is too large to include a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: true,
truncated: false,
warnings,
};
}
if (!buffer) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
if (isProbablyBinary(buffer)) {
warnings.push(warning("binary_file", "Binary files are summarized without a text patch.", input.filePath));
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: true,
oversized: false,
truncated: false,
warnings,
};
}
const content = buffer.toString("utf8");
const patch = reservePatchBytes(buildUntrackedPatch(input.filePath, content), input.budget, input.filePath, warnings);
return {
kind: "untracked",
patch,
additions: countAddedLines(content),
deletions: 0,
binary: false,
oversized: false,
truncated: patch === null,
warnings,
};
}
function applyPatchToFile(file: MutableWorkspaceDiffFile, patch: WorkspaceDiffFilePatch, sizeBytes: number | null) {
file.patches.push(patch);
file.additions += patch.additions;
file.deletions += patch.deletions;
file.binary = file.binary || patch.binary;
file.oversized = file.oversized || patch.oversized;
file.truncated = file.truncated || patch.truncated;
file.warnings.push(...patch.warnings);
if (file.sizeBytes === null && sizeBytes !== null) file.sizeBytes = sizeBytes;
}
function finalizeStats(files: WorkspaceDiffFile[]) {
return {
fileCount: files.length,
stagedFileCount: files.filter((file) => file.staged).length,
unstagedFileCount: files.filter((file) => file.unstaged).length,
untrackedFileCount: files.filter((file) => file.untracked).length,
binaryFileCount: files.filter((file) => file.binary).length,
oversizedFileCount: files.filter((file) => file.oversized).length,
truncatedFileCount: files.filter((file) => file.truncated).length,
additions: files.reduce((sum, file) => sum + file.additions, 0),
deletions: files.reduce((sum, file) => sum + file.deletions, 0),
};
}
async function resolveHeadSha(cwd: string) {
try {
return (await runGit(cwd, ["rev-parse", "HEAD"], 128 * 1024)).stdout.trim() || null;
} catch {
return null;
}
}
async function resolveBaseRef(cwd: string, baseRef: string | null, workspace: WorkspaceDiffTarget) {
const resolvedBaseRef = baseRef ?? workspace.baseRef ?? null;
if (!resolvedBaseRef) {
throw workspaceDiffError(
"base_ref_missing",
"A baseRef query parameter or execution workspace baseRef is required for head diffs",
{ workspaceId: workspace.id },
);
}
try {
await execFileAsync("git", ["-C", cwd, "rev-parse", "--verify", "--quiet", `${resolvedBaseRef}^{commit}`], {
cwd,
timeout: GIT_TIMEOUT_MS,
maxBuffer: 128 * 1024,
});
} catch {
throw workspaceDiffError(
"base_ref_invalid",
`Could not resolve baseRef "${resolvedBaseRef}" in this workspace`,
{ workspaceId: workspace.id, baseRef: resolvedBaseRef },
);
}
return resolvedBaseRef;
}
async function collectFiles(input: {
cwd: string;
workspace: WorkspaceDiffTarget;
query: WorkspaceDiffQueryOptions;
paths: string[];
}) {
const files = new Map<string, MutableWorkspaceDiffFile>();
let baseRef: string | null = null;
if (input.query.view === "head") {
baseRef = await resolveBaseRef(input.cwd, input.query.baseRef, input.workspace);
addStatusEntries(
files,
await readDiffNameStatus(input.cwd, [`${baseRef}...HEAD`], input.paths),
"head",
);
} else {
addStatusEntries(files, await readDiffNameStatus(input.cwd, ["--cached"], input.paths), "staged");
addStatusEntries(files, await readDiffNameStatus(input.cwd, [], input.paths), "unstaged");
if (input.query.includeUntracked) {
for (const untrackedPath of await readUntrackedPaths(input.cwd, input.paths)) {
const file = ensureFile(files, untrackedPath, "untracked", null);
file.untracked = true;
if (!file.patchScopes.includes("unstaged")) file.patchScopes.push("unstaged");
}
}
}
return { files, baseRef };
}
export function workspaceDiffService() {
return {
async getDiff(workspace: WorkspaceDiffTarget, query: WorkspaceDiffQueryOptions): Promise<WorkspaceDiffResponse> {
const { cwd, repoRoot } = await resolveWorkspacePaths(workspace);
const paths = normalizePathFilters(query.paths);
const warnings: WorkspaceDiffWarning[] = [];
const { files: filesByPath, baseRef } = await collectFiles({ cwd, workspace, query, paths });
const allFiles = Array.from(filesByPath.values()).sort((left, right) => left.path.localeCompare(right.path));
const cappedFiles = allFiles.slice(0, WORKSPACE_DIFF_CAPS.maxFiles);
if (allFiles.length > cappedFiles.length) {
warnings.push(warning(
"file_count_truncated",
`Workspace diff includes ${allFiles.length} files, so only the first ${WORKSPACE_DIFF_CAPS.maxFiles} are returned.`,
));
}
const patchBudget: PatchBudget = { totalPatchBytes: 0 };
for (const file of cappedFiles) {
if (query.view === "head") {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "head",
scopeArgs: [`${baseRef}...HEAD`],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
continue;
}
if (file.staged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "staged",
scopeArgs: ["--cached"],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.unstaged) {
const patch = await buildTrackedPatch({
cwd,
repoRoot,
filePath: file.path,
kind: "unstaged",
scopeArgs: [],
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
if (file.untracked) {
const patch = await buildUntrackedFilePatch({
repoRoot,
filePath: file.path,
budget: patchBudget,
});
applyPatchToFile(file, patch, await statWorkspaceFile(repoRoot, file.path));
}
}
const files = cappedFiles.map(({ patchScopes: _patchScopes, ...file }) => file);
const patchWarnings = files.flatMap((file) => file.warnings);
return {
workspaceId: workspace.id,
companyId: workspace.companyId,
view: query.view,
baseRef,
defaultBaseRef: workspace.baseRef,
headSha: await resolveHeadSha(cwd),
includeUntracked: query.includeUntracked,
paths,
files,
stats: finalizeStats(files),
warnings: [...warnings, ...patchWarnings],
caps: WORKSPACE_DIFF_CAPS,
truncated: warnings.some((item) => item.code === "file_count_truncated")
|| files.some((file) => file.truncated),
};
},
};
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from "vitest";
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "../src/contracts.js";
import { diffResponse } from "./fixtures.js";
describe("workspace diff plugin contracts", () => {
it("normalizes query options from plugin data parameters", () => {
expect(workspaceDiffQuerySchema.parse({
view: "head",
baseRef: " main ",
includeUntracked: "false",
path: ["src/app.ts, README.md", "packages/shared/src/index.ts"],
})).toEqual({
view: "head",
baseRef: "main",
includeUntracked: false,
paths: ["src/app.ts", "README.md", "packages/shared/src/index.ts"],
});
});
it("validates the plugin-owned response shape", () => {
expect(workspaceDiffResponseSchema.parse(diffResponse())).toMatchObject({
workspaceId: "11111111-1111-4111-8111-111111111111",
stats: { fileCount: 1 },
});
});
});
@@ -0,0 +1,193 @@
import { describe, expect, it } from "vitest";
import {
buildFilePatch,
buildFilePatches,
diffSummary,
initialExpandedFileSet,
LONG_DIFF_LINE_THRESHOLD,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
} from "../src/diff-model.js";
import { changedFile, diffResponse } from "./fixtures.js";
describe("workspace diff UI model", () => {
it("summarizes changed files and line counts", () => {
const diff = diffResponse();
expect(diffSummary(diff)).toMatchObject({
changedLabel: "1 file",
lineLabel: "+1 / -1",
warningCount: 0,
truncated: false,
});
expect(toFileViewModels(diff)[0]).toMatchObject({
path: "src/app.ts",
status: "modified",
patchKinds: ["unstaged"],
lineCount: 7,
longDiff: false,
});
});
it("represents empty workspace diffs", () => {
const diff = diffResponse({ files: [] });
expect(toFileViewModels(diff)).toEqual([]);
expect(diffSummary(diff).changedLabel).toBe("0 files");
});
it("surfaces truncation and file warnings", () => {
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
const file = changedFile({
truncated: true,
warnings: [warning],
patches: [],
});
const diff = diffResponse({ files: [file], truncated: true, warnings: [warning] });
expect(buildFilePatch(file)).toBeNull();
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
expect(diffSummary(diff)).toMatchObject({
warningCount: 1,
truncated: true,
});
});
it("does not duplicate aggregated patch warnings", () => {
const warning = { code: "patch_truncated" as const, message: "Patch was truncated.", path: "src/app.ts" };
const file = changedFile({
warnings: [warning],
patches: [
{
kind: "unstaged",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: true,
warnings: [warning],
},
],
});
const diff = diffResponse({ files: [file], warnings: [warning] });
expect(toFileViewModels(diff)[0]?.warnings).toEqual([warning]);
expect(diffSummary(diff).warningCount).toBe(1);
});
it("keeps staged and unstaged patches renderable as separate single-file diffs", () => {
const stagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-export const value = 1;",
"+export const value = 2;",
"",
].join("\n");
const unstagedPatch = [
"diff --git a/src/app.ts b/src/app.ts",
"index 2222222..3333333 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -3 +3 @@",
"-export const label = 'old';",
"+export const label = 'new';",
"",
].join("\n");
const file = changedFile({
staged: true,
unstaged: true,
patches: [
{
kind: "staged",
patch: stagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
{
kind: "unstaged",
patch: unstagedPatch,
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
});
const patches = buildFilePatches(file);
const viewModel = toFileViewModels(diffResponse({ files: [file] }))[0];
expect(buildFilePatch(file)).toBe(stagedPatch.trimEnd());
expect(patches.map((patch) => patch.kind)).toEqual(["staged", "unstaged"]);
expect(patches.map((patch) => patch.patch?.match(/^diff --git/gm)?.length ?? 0)).toEqual([1, 1]);
expect(viewModel?.patches).toHaveLength(2);
expect(viewModel?.patchKinds).toEqual(["staged", "unstaged"]);
});
it("marks long text diffs so the UI can fold them by default", () => {
const longPatch = [
"diff --git a/src/large.ts b/src/large.ts",
"index 1111111..2222222 100644",
"--- a/src/large.ts",
"+++ b/src/large.ts",
"@@ -1,1 +1,1 @@",
...Array.from({ length: LONG_DIFF_LINE_THRESHOLD }, (_, index) => `+export const value${index} = ${index};`),
"",
].join("\n");
const files = toFileViewModels(diffResponse({
files: [
changedFile({ path: "src/small.ts" }),
changedFile({
path: "src/large.ts",
additions: LONG_DIFF_LINE_THRESHOLD,
deletions: 0,
patches: [
{
kind: "unstaged",
patch: longPatch,
additions: LONG_DIFF_LINE_THRESHOLD,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
}),
],
}));
const longFile = files.find((file) => file.path === "src/large.ts");
const defaultExpanded = initialExpandedFileSet(files);
expect(longFile?.lineCount).toBeGreaterThan(LONG_DIFF_LINE_THRESHOLD);
expect(longFile?.longDiff).toBe(true);
expect(defaultExpanded.has("src/small.ts")).toBe(true);
expect(defaultExpanded.has("src/large.ts")).toBe(false);
});
it("toggles expanded file state without mutating the current set", () => {
const current = new Set(["a.ts"]);
const collapsed = nextExpandedFileSet(current, "a.ts");
const expanded = nextExpandedFileSet(current, "b.ts");
expect(current.has("a.ts")).toBe(true);
expect(collapsed.has("a.ts")).toBe(false);
expect(expanded.has("b.ts")).toBe(true);
});
it("labels file statuses for the sidebar", () => {
expect(statusLabel("untracked")).toBe("Untracked");
expect(statusLabel("type_changed")).toBe("Type changed");
});
});
@@ -0,0 +1,78 @@
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "../src/contracts.js";
export function changedFile(overrides: Partial<WorkspaceDiffFile> = {}): WorkspaceDiffFile {
return {
path: "src/app.ts",
oldPath: null,
status: "modified",
staged: false,
unstaged: true,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 1,
deletions: 1,
sizeBytes: 120,
patches: [
{
kind: "unstaged",
patch: [
"diff --git a/src/app.ts b/src/app.ts",
"index 1111111..2222222 100644",
"--- a/src/app.ts",
"+++ b/src/app.ts",
"@@ -1 +1 @@",
"-export const value = 1;",
"+export const value = 2;",
"",
].join("\n"),
additions: 1,
deletions: 1,
binary: false,
oversized: false,
truncated: false,
warnings: [],
},
],
warnings: [],
...overrides,
};
}
export function diffResponse(overrides: Partial<WorkspaceDiffResponse> = {}): WorkspaceDiffResponse {
const files = overrides.files ?? [changedFile()];
const additions = files.reduce((sum, file) => sum + file.additions, 0);
const deletions = files.reduce((sum, file) => sum + file.deletions, 0);
return {
workspaceId: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
view: "working-tree",
baseRef: null,
defaultBaseRef: null,
headSha: null,
includeUntracked: true,
paths: [],
files,
stats: {
fileCount: files.length,
stagedFileCount: files.filter((file) => file.staged).length,
unstagedFileCount: files.filter((file) => file.unstaged).length,
untrackedFileCount: files.filter((file) => file.untracked).length,
binaryFileCount: files.filter((file) => file.binary).length,
oversizedFileCount: files.filter((file) => file.oversized).length,
truncatedFileCount: files.filter((file) => file.truncated).length,
additions,
deletions,
},
warnings: [],
caps: {
maxFiles: 200,
maxFileBytes: 524288,
maxPatchBytes: 131072,
maxTotalPatchBytes: 1048576,
},
truncated: false,
...overrides,
};
}
@@ -0,0 +1,238 @@
import { execFile } from "node:child_process";
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin, { resolveDefaultBaseRef } from "../src/worker.js";
const execFileAsync = promisify(execFile);
const tempRoots: string[] = [];
async function git(cwd: string, args: string[]) {
return execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function createGitWorkspace() {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-workspace-diff-plugin-"));
tempRoots.push(root);
await fs.mkdir(path.join(root, "src"), { recursive: true });
await git(root, ["init"]);
await git(root, ["config", "user.email", "paperclip@example.com"]);
await git(root, ["config", "user.name", "Paperclip Test"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 1;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "initial"]);
await git(root, ["branch", "-M", "main"]);
return root;
}
describe("workspace diff plugin", () => {
afterEach(async () => {
await Promise.all(tempRoots.map((root) => fs.rm(root, { recursive: true, force: true })));
tempRoots.length = 0;
});
it("declares workspace Changes tabs and workspace read capabilities", () => {
expect(manifest.capabilities).toContain("ui.detailTab.register");
expect(manifest.capabilities).toContain("execution.workspaces.read");
expect(manifest.capabilities).toContain("project.workspaces.read");
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
type: "detailTab",
displayName: "Changes",
entityTypes: ["execution_workspace", "project_workspace"],
}));
});
it("fetches changed execution workspace diffs from host metadata", async () => {
const root = await createGitWorkspace();
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 2;\n");
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: "HEAD",
branchName: "main",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "working-tree",
includeUntracked: false,
paths: ["src/app.ts"],
});
expect(result).toMatchObject({
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns an empty diff when the workspace has no changes", async () => {
const root = await createGitWorkspace();
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: root,
cwd: root,
repoUrl: null,
baseRef: "HEAD",
branchName: "main",
providerType: "git_worktree",
providerMetadata: null,
}],
});
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
})).resolves.toMatchObject({ files: [], truncated: false });
});
it("fetches project workspace diffs from generic project workspace metadata", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 3;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "project workspace change"]);
const harness = createTestHarness({ manifest });
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
projectId: "project-1",
entityType: "project_workspace",
view: "head",
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("resolves the default base ref from workspace and project workspace metadata", () => {
expect(resolveDefaultBaseRef({
workspaceBaseRef: " release/main ",
projectWorkspaceDefaultRef: "origin/main",
projectWorkspaceRepoRef: "feature",
})).toBe("release/main");
expect(resolveDefaultBaseRef({
workspaceBaseRef: null,
projectWorkspaceDefaultRef: " origin/main ",
projectWorkspaceRepoRef: "feature",
})).toBe("origin/main");
expect(resolveDefaultBaseRef({
workspaceBaseRef: "",
projectWorkspaceDefaultRef: null,
projectWorkspaceRepoRef: " feature ",
})).toBe("feature");
expect(resolveDefaultBaseRef({
workspaceBaseRef: "",
projectWorkspaceDefaultRef: null,
projectWorkspaceRepoRef: "",
})).toBeNull();
});
it("uses project workspace default refs for execution workspace head diffs", async () => {
const root = await createGitWorkspace();
await git(root, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(root, "src/app.ts"), "export const value = 4;\n");
await git(root, ["add", "src/app.ts"]);
await git(root, ["commit", "-m", "feature change"]);
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
path: root,
cwd: root,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
}],
});
harness.ctx.projects.listWorkspaces = async (projectId, companyId) => {
expect(projectId).toBe("project-1");
expect(companyId).toBe("company-1");
return [{
id: "project-workspace-1",
projectId: "project-1",
name: "Primary",
path: root,
repoUrl: null,
repoRef: "feature",
defaultRef: "main",
isPrimary: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}];
};
await plugin.definition.setup(harness.ctx);
const result = await harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
view: "head",
includeUntracked: false,
});
expect(result).toMatchObject({
baseRef: "main",
defaultBaseRef: "main",
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns a clear bridge error when required context is missing", async () => {
const harness = createTestHarness({ manifest });
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
})).rejects.toThrow("workspaceId and companyId are required");
});
});
@@ -0,0 +1,20 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { describe, expect, it } from "vitest";
import { ErrorState } from "../src/ui/index.js";
describe("workspace diff error state", () => {
it("keeps bridge error details out of the primary headline", () => {
const rawError = "Execution workspace not found";
const html = renderToStaticMarkup(createElement(ErrorState, {
message: rawError,
onRetry: () => undefined,
}));
expect(html).toContain("Unable to load workspace changes.");
expect(html).toContain("Retry");
expect(html).toContain("Troubleshooting details");
expect(html).not.toContain(`font-medium text-foreground">${rawError}`);
expect(html.indexOf(rawError)).toBeGreaterThan(html.indexOf("Troubleshooting details"));
});
});
@@ -0,0 +1,200 @@
import { execFile } from "node:child_process";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { promisify } from "node:util";
import { afterEach, describe, expect, it } from "vitest";
import type { PluginExecutionWorkspaceMetadata } from "@paperclipai/plugin-sdk";
import type { WorkspaceDiffQueryOptions } from "../src/contracts.js";
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../src/workspace-diff.js";
const execFileAsync = promisify(execFile);
const tempDirs = new Set<string>();
async function runGit(cwd: string, args: string[]) {
await execFileAsync("git", ["-C", cwd, ...args], { cwd });
}
async function createTempRepo() {
const repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-"));
tempDirs.add(repoRoot);
await runGit(repoRoot, ["init"]);
await runGit(repoRoot, ["config", "user.name", "Paperclip Test"]);
await runGit(repoRoot, ["config", "user.email", "test@paperclip.local"]);
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\n", "utf8");
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\n", "utf8");
await fs.writeFile(path.join(repoRoot, "delete-me.txt"), "charlie\n", "utf8");
await fs.writeFile(path.join(repoRoot, "rename-me.txt"), "delta\n", "utf8");
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3]));
await runGit(repoRoot, ["add", "."]);
await runGit(repoRoot, ["commit", "-m", "Initial commit"]);
await runGit(repoRoot, ["branch", "-M", "main"]);
return repoRoot;
}
function createWorkspace(cwd: string | null, overrides: Partial<PluginExecutionWorkspaceMetadata> = {}): PluginExecutionWorkspaceMetadata {
return {
id: randomUUID(),
companyId: randomUUID(),
projectId: randomUUID(),
projectWorkspaceId: null,
path: cwd,
cwd,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerMetadata: null,
...overrides,
};
}
function workingTreeQuery(overrides: Partial<WorkspaceDiffQueryOptions> = {}): WorkspaceDiffQueryOptions {
return {
view: "working-tree",
baseRef: null,
includeUntracked: true,
paths: [],
...overrides,
};
}
afterEach(async () => {
for (const dir of tempDirs) {
await fs.rm(dir, { recursive: true, force: true });
}
tempDirs.clear();
});
describe("plugin workspace diff service", () => {
it("returns staged, unstaged, renamed, deleted, untracked, binary, and oversized working-tree changes", async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\nstaged\n", "utf8");
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\nunstaged\n", "utf8");
await runGit(repoRoot, ["mv", "rename-me.txt", "renamed.txt"]);
await fs.rm(path.join(repoRoot, "delete-me.txt"));
await fs.writeFile(path.join(repoRoot, "binary.bin"), Buffer.from([0, 1, 2, 3, 4, 5]));
await fs.writeFile(path.join(repoRoot, "untracked.txt"), "brand new\n", "utf8");
await fs.writeFile(path.join(repoRoot, "empty-untracked.txt"), "", "utf8");
await fs.writeFile(path.join(repoRoot, "oversized.txt"), "x".repeat(WORKSPACE_DIFF_CAPS.maxFileBytes + 1), "utf8");
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
const byPath = new Map(diff.files.map((file) => [file.path, file]));
expect(diff.view).toBe("working-tree");
expect(byPath.get("tracked-staged.txt")).toMatchObject({ staged: true, unstaged: false, status: "modified", additions: 1 });
expect(byPath.get("tracked-staged.txt")?.patches.map((patch) => patch.kind)).toEqual(["staged"]);
expect(byPath.get("tracked-unstaged.txt")).toMatchObject({ staged: false, unstaged: true, status: "modified", additions: 1 });
expect(byPath.get("renamed.txt")).toMatchObject({ oldPath: "rename-me.txt", staged: true, status: "renamed" });
expect(byPath.get("delete-me.txt")).toMatchObject({ unstaged: true, status: "deleted", deletions: 1 });
expect(byPath.get("untracked.txt")).toMatchObject({ untracked: true, status: "untracked", additions: 1 });
expect(byPath.get("untracked.txt")?.patches[0]?.patch).toContain("+brand new");
expect(byPath.get("empty-untracked.txt")?.patches[0]?.patch).toBe([
"diff --git a/empty-untracked.txt b/empty-untracked.txt",
"new file mode 100644",
"--- /dev/null",
"+++ b/empty-untracked.txt",
"",
].join("\n"));
expect(byPath.get("binary.bin")).toMatchObject({ binary: true, unstaged: true });
expect(byPath.get("oversized.txt")).toMatchObject({ oversized: true, untracked: true });
expect(diff.warnings.map((item) => item.code)).toEqual(expect.arrayContaining(["binary_file", "file_oversized"]));
}, 20_000);
it("returns head diffs against the requested base ref", async () => {
const repoRoot = await createTempRepo();
await runGit(repoRoot, ["checkout", "-b", "feature"]);
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\ncommitted\n", "utf8");
await runGit(repoRoot, ["add", "tracked-staged.txt"]);
await runGit(repoRoot, ["commit", "-m", "Feature change"]);
const diff = await workspaceDiffService().getDiff(
createWorkspace(repoRoot, { baseRef: "main" }),
workingTreeQuery({ view: "head", includeUntracked: false }),
);
expect(diff.baseRef).toBe("main");
expect(diff.files).toHaveLength(1);
expect(diff.files[0]).toMatchObject({
path: "tracked-staged.txt",
staged: false,
unstaged: false,
untracked: false,
additions: 1,
deletions: 0,
});
expect(diff.files[0]?.patches.map((patch) => patch.kind)).toEqual(["head"]);
}, 20_000);
it("filters changed files by relative workspace paths", async () => {
const repoRoot = await createTempRepo();
await fs.writeFile(path.join(repoRoot, "tracked-staged.txt"), "alpha\none\n", "utf8");
await fs.writeFile(path.join(repoRoot, "tracked-unstaged.txt"), "bravo\ntwo\n", "utf8");
const diff = await workspaceDiffService().getDiff(
createWorkspace(repoRoot),
workingTreeQuery({ paths: ["tracked-staged.txt"] }),
);
expect(diff.paths).toEqual(["tracked-staged.txt"]);
expect(diff.files.map((file) => file.path)).toEqual(["tracked-staged.txt"]);
}, 20_000);
it("applies output caps to large workspace responses", async () => {
const repoRoot = await createTempRepo();
for (let index = 0; index < WORKSPACE_DIFF_CAPS.maxFiles + 1; index += 1) {
await fs.writeFile(path.join(repoRoot, `untracked-${String(index).padStart(3, "0")}.txt`), "", "utf8");
}
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
expect(diff.files).toHaveLength(WORKSPACE_DIFF_CAPS.maxFiles);
expect(diff.truncated).toBe(true);
expect(diff.warnings).toContainEqual(expect.objectContaining({ code: "file_count_truncated" }));
}, 20_000);
it("does not follow untracked symlinks outside the repo", async () => {
const repoRoot = await createTempRepo();
const outsideDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-secret-"));
tempDirs.add(outsideDir);
const secretContent = "external secret should not appear\n";
const secretPath = path.join(outsideDir, "secret.txt");
await fs.writeFile(secretPath, secretContent, "utf8");
await fs.symlink(secretPath, path.join(repoRoot, "leak.txt"));
const diff = await workspaceDiffService().getDiff(createWorkspace(repoRoot), workingTreeQuery());
const leak = diff.files.find((file) => file.path === "leak.txt");
const serialized = JSON.stringify(diff);
expect(leak).toMatchObject({ untracked: true, status: "untracked", additions: 0, sizeBytes: null });
expect(leak?.patches[0]).toMatchObject({
kind: "untracked",
patch: null,
warnings: [expect.objectContaining({ code: "symlink_target_outside_workspace" })],
});
expect(diff.warnings).toContainEqual(expect.objectContaining({
code: "symlink_target_outside_workspace",
path: "leak.txt",
}));
expect(serialized).not.toContain(secretContent.trim());
}, 20_000);
it("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as plugin errors", async () => {
const svc = workspaceDiffService();
await expect(svc.getDiff(createWorkspace(null), workingTreeQuery()))
.rejects.toMatchObject({ status: 422, details: { code: "missing_cwd" } });
const nonGitDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-plugin-workspace-diff-non-git-"));
tempDirs.add(nonGitDir);
await expect(svc.getDiff(createWorkspace(nonGitDir), workingTreeQuery()))
.rejects.toMatchObject({ status: 422, details: { code: "non_git_workspace" } });
const repoRoot = await createTempRepo();
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ paths: ["../secret"] })))
.rejects.toMatchObject({ status: 422, details: { code: "path_filter_invalid" } });
await expect(svc.getDiff(createWorkspace(repoRoot), workingTreeQuery({ view: "head", baseRef: "missing-ref" })))
.rejects.toMatchObject({ status: 422, details: { code: "base_ref_invalid" } });
}, 20_000);
});
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022", "DOM"],
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"],
"exclude": ["dist", "node_modules"]
}
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"rootDir": "."
},
"include": ["src", "tests"]
}
@@ -0,0 +1,8 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/**/*.spec.ts"],
environment: "node",
},
});
@@ -181,6 +181,11 @@ export interface HostServices {
resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>; resetManaged(params: WorkerToHostMethods["projects.managed.reset"][0]): Promise<WorkerToHostMethods["projects.managed.reset"][1]>;
}; };
/** Provides `executionWorkspaces.get`. */
executionWorkspaces: {
get(params: WorkerToHostMethods["executionWorkspaces.get"][0]): Promise<WorkerToHostMethods["executionWorkspaces.get"][1]>;
};
/** Provides `routines.managed.*`. */ /** Provides `routines.managed.*`. */
routines: { routines: {
managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>; managedGet(params: WorkerToHostMethods["routines.managed.get"][0]): Promise<WorkerToHostMethods["routines.managed.get"][1]>;
@@ -368,6 +373,7 @@ const METHOD_CAPABILITY_MAP: Record<WorkerToHostMethodName, PluginCapability | n
"projects.listWorkspaces": "project.workspaces.read", "projects.listWorkspaces": "project.workspaces.read",
"projects.getPrimaryWorkspace": "project.workspaces.read", "projects.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read", "projects.getWorkspaceForIssue": "project.workspaces.read",
"executionWorkspaces.get": "execution.workspaces.read",
"projects.managed.get": "projects.managed", "projects.managed.get": "projects.managed",
"projects.managed.reconcile": "projects.managed", "projects.managed.reconcile": "projects.managed",
"projects.managed.reset": "projects.managed", "projects.managed.reset": "projects.managed",
@@ -608,6 +614,9 @@ export function createHostClientHandlers(
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => { "projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
return services.projects.getWorkspaceForIssue(params); return services.projects.getWorkspaceForIssue(params);
}), }),
"executionWorkspaces.get": gated("executionWorkspaces.get", async (params) => {
return services.executionWorkspaces.get(params);
}),
"projects.managed.get": gated("projects.managed.get", async (params) => { "projects.managed.get": gated("projects.managed.get", async (params) => {
return services.projects.getManaged(params); return services.projects.getManaged(params);
}), }),
+2
View File
@@ -197,6 +197,7 @@ export type {
PluginStateClient, PluginStateClient,
PluginEntitiesClient, PluginEntitiesClient,
PluginProjectsClient, PluginProjectsClient,
PluginExecutionWorkspacesClient,
PluginSkillsClient, PluginSkillsClient,
PluginCompaniesClient, PluginCompaniesClient,
PluginIssuesClient, PluginIssuesClient,
@@ -244,6 +245,7 @@ export type {
PluginEntityRecord, PluginEntityRecord,
PluginEntityQuery, PluginEntityQuery,
PluginWorkspace, PluginWorkspace,
PluginExecutionWorkspaceMetadata,
Company, Company,
Project, Project,
Issue, Issue,
+8
View File
@@ -51,6 +51,7 @@ import type {
PluginIssueWakeupBatchResult, PluginIssueWakeupBatchResult,
PluginIssueWakeupResult, PluginIssueWakeupResult,
PluginJobContext, PluginJobContext,
PluginExecutionWorkspaceMetadata,
PluginWorkspace, PluginWorkspace,
ToolRunContext, ToolRunContext,
ToolResult, ToolResult,
@@ -777,6 +778,13 @@ export interface WorkerToHostMethods {
params: { issueId: string; companyId: string }, params: { issueId: string; companyId: string },
result: PluginWorkspace | null, result: PluginWorkspace | null,
]; ];
"executionWorkspaces.get": [
params: {
workspaceId: string;
companyId: string;
},
result: PluginExecutionWorkspaceMetadata | null,
];
"projects.managed.get": [ "projects.managed.get": [
params: { projectKey: string; companyId: string }, params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution, result: PluginManagedProjectResolution,
+17
View File
@@ -33,6 +33,7 @@ import type {
ToolResult, ToolResult,
ToolRunContext, ToolRunContext,
PluginWorkspace, PluginWorkspace,
PluginExecutionWorkspaceMetadata,
AgentSession, AgentSession,
AgentSessionEvent, AgentSessionEvent,
PluginLocalFolderEntry, PluginLocalFolderEntry,
@@ -80,6 +81,8 @@ export interface TestHarness {
issueComments?: IssueComment[]; issueComments?: IssueComment[];
agents?: Agent[]; agents?: Agent[];
goals?: Goal[]; goals?: Goal[];
projectWorkspaces?: PluginWorkspace[];
executionWorkspaces?: PluginExecutionWorkspaceMetadata[];
}): void; }): void;
setConfig(config: Record<string, unknown>): void; setConfig(config: Record<string, unknown>): void;
/** Dispatch a host or plugin event to registered handlers. */ /** Dispatch a host or plugin event to registered handlers. */
@@ -438,6 +441,7 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
const agents = new Map<string, Agent>(); const agents = new Map<string, Agent>();
const goals = new Map<string, Goal>(); const goals = new Map<string, Goal>();
const projectWorkspaces = new Map<string, PluginWorkspace[]>(); const projectWorkspaces = new Map<string, PluginWorkspace[]>();
const executionWorkspaces = new Map<string, PluginExecutionWorkspaceMetadata>();
const localFolderStatuses = new Map<string, PluginLocalFolderStatus>(); const localFolderStatuses = new Map<string, PluginLocalFolderStatus>();
const localFolderFiles = new Map<string, string>(); const localFolderFiles = new Map<string, string>();
@@ -975,6 +979,13 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
}, },
}, },
}, },
executionWorkspaces: {
async get(workspaceId, companyId) {
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
const workspace = executionWorkspaces.get(workspaceId);
return workspace?.companyId === companyId ? workspace : null;
},
},
routines: { routines: {
managed: { managed: {
async get(routineKey, companyId) { async get(routineKey, companyId) {
@@ -2048,6 +2059,12 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
} }
for (const row of input.agents ?? []) agents.set(row.id, row); for (const row of input.agents ?? []) agents.set(row.id, row);
for (const row of input.goals ?? []) goals.set(row.id, row); for (const row of input.goals ?? []) goals.set(row.id, row);
for (const row of input.projectWorkspaces ?? []) {
const list = projectWorkspaces.get(row.projectId) ?? [];
list.push(row);
projectWorkspaces.set(row.projectId, list);
}
for (const row of input.executionWorkspaces ?? []) executionWorkspaces.set(row.id, row);
}, },
setConfig(config) { setConfig(config) {
currentConfig = { ...config }; currentConfig = { ...config };
+56
View File
@@ -344,6 +344,12 @@ export interface PluginWorkspace {
name: string; name: string;
/** Absolute filesystem path to the workspace directory. */ /** Absolute filesystem path to the workspace directory. */
path: string; path: string;
/** Repository URL, when known. */
repoUrl: string | null;
/** Checkout/ref requested for the workspace, when known. */
repoRef: string | null;
/** Default comparison ref for workspace tooling, when known. */
defaultRef: string | null;
/** Whether this is the project's primary workspace. */ /** Whether this is the project's primary workspace. */
isPrimary: boolean; isPrimary: boolean;
/** ISO 8601 creation timestamp. */ /** ISO 8601 creation timestamp. */
@@ -352,6 +358,40 @@ export interface PluginWorkspace {
updatedAt: string; updatedAt: string;
} }
// ---------------------------------------------------------------------------
// Execution workspace metadata (read-only via ctx.executionWorkspaces)
// ---------------------------------------------------------------------------
/**
* Plugin-safe execution workspace metadata provided by the host. This exposes
* the local/repository coordinates plugins need for workspace tooling without
* giving the SDK a host-owned diff engine.
*/
export interface PluginExecutionWorkspaceMetadata {
/** UUID primary key. */
id: string;
/** UUID of the owning company. */
companyId: string;
/** UUID of the parent project. */
projectId: string;
/** UUID of the backing project workspace, when present. */
projectWorkspaceId: string | null;
/** Absolute filesystem path to the workspace when locally realized. */
path: string | null;
/** Current working directory for local workspace tooling. */
cwd: string | null;
/** Repository URL, when known. */
repoUrl: string | null;
/** Base ref configured for the workspace, when known. */
baseRef: string | null;
/** Branch name configured for the workspace, when known. */
branchName: string | null;
/** Host provider type for the realized workspace. */
providerType: string | null;
/** Provider metadata already safe for plugin consumption. */
providerMetadata: Record<string, unknown> | null;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Host API surfaces exposed via PluginContext // Host API surfaces exposed via PluginContext
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -818,6 +858,19 @@ export interface PluginProjectsClient {
}; };
} }
/**
* `ctx.executionWorkspaces` — read execution workspace metadata.
*
* Requires `execution.workspaces.read`.
*/
export interface PluginExecutionWorkspacesClient {
/**
* Return plugin-safe metadata for an execution workspace. The host enforces
* company access before returning any workspace coordinates.
*/
get(workspaceId: string, companyId: string): Promise<PluginExecutionWorkspaceMetadata | null>;
}
/** /**
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines. * `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
* *
@@ -1642,6 +1695,9 @@ export interface PluginContext {
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */ /** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
projects: PluginProjectsClient; projects: PluginProjectsClient;
/** Read execution workspace metadata. Requires `execution.workspaces.read`. */
executionWorkspaces: PluginExecutionWorkspacesClient;
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */ /** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
routines: PluginRoutinesClient; routines: PluginRoutinesClient;
@@ -657,6 +657,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
}, },
}, },
executionWorkspaces: {
async get(workspaceId: string, companyId: string) {
return callHost("executionWorkspaces.get", { workspaceId, companyId });
},
},
routines: { routines: {
managed: { managed: {
async get(routineKey: string, companyId: string) { async get(routineKey: string, companyId: string) {
+3
View File
@@ -726,6 +726,7 @@ export const PLUGIN_CAPABILITIES = [
"companies.read", "companies.read",
"projects.read", "projects.read",
"project.workspaces.read", "project.workspaces.read",
"execution.workspaces.read",
"issues.read", "issues.read",
"issue.relations.read", "issue.relations.read",
"issue.subtree.read", "issue.subtree.read",
@@ -961,6 +962,8 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
"goal", "goal",
"run", "run",
"comment", "comment",
"execution_workspace",
"project_workspace",
] as const; ] as const;
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number]; export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
@@ -104,4 +104,28 @@ describe("plugin UI slot validators", () => {
if (parsed.success) return; if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true); expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
}); });
it("accepts workspace entity types as detailTab targets", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "detailTab",
id: "workspace-diff-viewer",
displayName: "Diff",
exportName: "WorkspaceDiffViewer",
entityTypes: ["execution_workspace", "project_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace", "project_workspace"]);
});
it("accepts execution_workspace as a toolbarButton entityType", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "toolbarButton",
id: "workspace-open-diff",
displayName: "Open diff",
exportName: "OpenWorkspaceDiffButton",
entityTypes: ["execution_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
}); });
+6
View File
@@ -52,3 +52,9 @@ test("resolveTargetPackage matches by package name or dir", () => {
assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a"); assert.equal(resolveTargetPackage("@paperclipai/a", packages).dir, "packages/a");
assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b"); assert.equal(resolveTargetPackage("./packages/b", packages).name, "@paperclipai/b");
}); });
test("resolveTargetPackage includes the workspace diff plugin bootstrap package", () => {
const pkg = resolveTargetPackage("@paperclipai/plugin-workspace-diff");
assert.equal(pkg.dir, "packages/plugins/plugin-workspace-diff");
});
+5
View File
@@ -69,6 +69,11 @@
"name": "@paperclipai/plugin-sdk", "name": "@paperclipai/plugin-sdk",
"publishFromCi": true "publishFromCi": true
}, },
{
"dir": "packages/plugins/plugin-workspace-diff",
"name": "@paperclipai/plugin-workspace-diff",
"publishFromCi": false
},
{ {
"dir": "server", "dir": "server",
"name": "@paperclipai/server", "name": "@paperclipai/server",
@@ -25,15 +25,15 @@ vi.mock("../services/index.js", () => ({
workspaceOperationService: () => mockWorkspaceOperationService, workspaceOperationService: () => mockWorkspaceOperationService,
})); }));
function createApp() { function createApp(companyIds = ["company-1"]) {
const app = express(); const app = express();
app.use(express.json()); app.use(express.json());
app.use((req, _res, next) => { app.use((req, _res, next) => {
(req as any).actor = { (req as any).actor = {
type: "board", type: "board",
userId: "local-board", userId: "local-board",
companyIds: ["company-1"], companyIds,
source: "local_implicit", source: "session",
isInstanceAdmin: false, isInstanceAdmin: false,
}; };
next(); next();
@@ -55,6 +55,7 @@ describe.sequential("execution workspace routes", () => {
projectWorkspaceId: null, projectWorkspaceId: null,
}, },
]); ]);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
}); });
it("uses summary mode for lightweight workspace lookups", async () => { it("uses summary mode for lightweight workspace lookups", async () => {
@@ -79,4 +80,5 @@ describe.sequential("execution workspace routes", () => {
}); });
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled(); expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
}); });
}); });
@@ -800,4 +800,43 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => {
expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false);
expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true);
}); });
it("removes closed liveness escalations from blocker relations during reconciliation", async () => {
await enableAutoRecovery();
const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain();
const heartbeat = heartbeatService(db);
const first = await heartbeat.reconcileIssueGraphLiveness();
expect(first.escalationsCreated).toBe(1);
const escalations = await db
.select()
.from(issues)
.where(
and(
eq(issues.companyId, companyId),
eq(issues.originKind, "harness_liveness_escalation"),
),
);
expect(escalations).toHaveLength(1);
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, escalations[0]!.id));
await db
.update(issues)
.set({ status: "done", blockedByIssueIds: [] })
.where(eq(issues.id, blockerIssueId));
const second = await heartbeat.reconcileIssueGraphLiveness();
expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0);
expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1);
const blockers = await db
.select({ blockerIssueId: issueRelations.issueId })
.from(issueRelations)
.where(eq(issueRelations.relatedIssueId, blockedIssueId));
expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false);
});
}); });
@@ -0,0 +1,54 @@
import { describe, expect, it, vi } from "vitest";
import { createHostClientHandlers } from "../../../packages/plugins/sdk/src/host-client-factory.js";
import { PLUGIN_RPC_ERROR_CODES } from "../../../packages/plugins/sdk/src/protocol.js";
describe("plugin execution workspace bridge", () => {
it("routes metadata reads through the host client when the capability is declared", async () => {
const get = vi.fn().mockResolvedValue({
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
path: "/tmp/workspace-1",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: "main",
branchName: "feature/workspace-1",
providerType: "git_worktree",
providerMetadata: null,
});
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: ["execution.workspaces.read"],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/workspace-1",
});
expect(get).toHaveBeenCalledWith({ workspaceId: "workspace-1", companyId: "company-1" });
});
it("rejects metadata reads when the plugin lacks execution.workspace read access", async () => {
const get = vi.fn();
const handlers = createHostClientHandlers({
pluginId: "workspace-plugin",
capabilities: [],
services: {
executionWorkspaces: { get },
} as any,
});
await expect(
handlers["executionWorkspaces.get"]({ workspaceId: "workspace-1", companyId: "company-1" }),
).rejects.toMatchObject({
code: PLUGIN_RPC_ERROR_CODES.CAPABILITY_DENIED,
});
expect(get).not.toHaveBeenCalled();
});
});
@@ -11,6 +11,7 @@ import {
companies, companies,
costEvents, costEvents,
createDb, createDb,
executionWorkspaces,
heartbeatRuns, heartbeatRuns,
issueRelations, issueRelations,
issues, issues,
@@ -67,6 +68,7 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
await db.delete(agentWakeupRequests); await db.delete(agentWakeupRequests);
await db.delete(issueRelations); await db.delete(issueRelations);
await db.delete(issues); await db.delete(issues);
await db.delete(executionWorkspaces);
await db.delete(pluginManagedResources); await db.delete(pluginManagedResources);
await db.delete(projects); await db.delete(projects);
await db.delete(plugins); await db.delete(plugins);
@@ -107,6 +109,61 @@ describeEmbeddedPostgres("plugin orchestration APIs", () => {
return root; return root;
} }
it("returns plugin-safe execution workspace metadata scoped to the company", async () => {
const { companyId } = await seedCompanyAndAgent();
const otherCompanyId = randomUUID();
const projectId = randomUUID();
const workspaceId = randomUUID();
await db.insert(companies).values({
id: otherCompanyId,
name: "Other",
issuePrefix: issuePrefix(otherCompanyId),
requireBoardApprovalForNewAgents: false,
});
await db.insert(projects).values({
id: projectId,
companyId,
name: "Workspaces",
status: "in_progress",
});
await db.insert(executionWorkspaces).values({
id: workspaceId,
companyId,
projectId,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerRef: "/tmp/paperclip-feature",
metadata: {
providerMetadata: { sandboxId: "sandbox-1" },
workspaceRealizationRequest: { hiddenInternal: true },
},
});
const services = buildHostServices(db, "plugin-record-id", "paperclip.workspace", createEventBusStub());
await expect(services.executionWorkspaces.get({ workspaceId, companyId })).resolves.toMatchObject({
id: workspaceId,
companyId,
projectId,
projectWorkspaceId: null,
path: "/tmp/paperclip-feature",
cwd: "/tmp/paperclip-feature",
repoUrl: "https://example.com/paperclip.git",
baseRef: "main",
branchName: "feature/workspace",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(services.executionWorkspaces.get({ workspaceId, companyId: otherCompanyId })).resolves.toBeNull();
});
it("creates plugin-origin issues with full orchestration fields and audit activity", async () => { it("creates plugin-origin issues with full orchestration fields and audit activity", async () => {
const { companyId, agentId } = await seedCompanyAndAgent(); const { companyId, agentId } = await seedCompanyAndAgent();
const blockerIssueId = randomUUID(); const blockerIssueId = randomUUID();
@@ -3,6 +3,63 @@ import type { PaperclipPluginManifestV1 } from "@paperclipai/shared";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing"; import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
describe("plugin SDK test harness", () => { describe("plugin SDK test harness", () => {
it("returns scoped execution workspace metadata with the read capability", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-execution-workspace-metadata",
apiVersion: 1,
version: "0.1.0",
displayName: "Execution Workspace Metadata",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: ["execution.workspaces.read"],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
harness.seed({
executionWorkspaces: [{
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: "project-workspace-1",
path: "/tmp/paperclip-test",
cwd: "/tmp/paperclip-test",
repoUrl: "https://example.com/repo.git",
baseRef: "main",
branchName: "feature/test",
providerType: "git_worktree",
providerMetadata: { sandboxId: "sandbox-1" },
}],
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).resolves.toMatchObject({
id: "workspace-1",
cwd: "/tmp/paperclip-test",
branchName: "feature/test",
providerMetadata: { sandboxId: "sandbox-1" },
});
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-2")).resolves.toBeNull();
});
it("requires execution.workspaces.read before returning workspace metadata", async () => {
const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-execution-workspace-read",
apiVersion: 1,
version: "0.1.0",
displayName: "Missing Workspace Read Capability",
description: "Test plugin",
author: "Paperclip",
categories: ["automation"],
capabilities: [],
entrypoints: { worker: "./dist/worker.js" },
};
const harness = createTestHarness({ manifest });
await expect(harness.ctx.executionWorkspaces.get("workspace-1", "company-1")).rejects.toThrow(
"missing required capability 'execution.workspaces.read'",
);
});
it("requires skills.managed capability before resetting a missing declaration", async () => { it("requires skills.managed capability before resetting a missing declaration", async () => {
const manifest: PaperclipPluginManifestV1 = { const manifest: PaperclipPluginManifestV1 = {
id: "paperclip.test-missing-managed-skill-capability", id: "paperclip.test-missing-managed-skill-capability",
+9 -1
View File
@@ -120,7 +120,7 @@ interface AvailablePluginExample {
displayName: string; displayName: string;
description: string; description: string;
localPath: string; localPath: string;
tag: "example"; tag: "example" | "first-party";
} }
/** Response body for GET /api/plugins/:pluginId/health */ /** Response body for GET /api/plugins/:pluginId/health */
@@ -152,6 +152,14 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = path.resolve(__dirname, "../../.."); const REPO_ROOT = path.resolve(__dirname, "../../..");
const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [ const BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
{
packageName: "@paperclipai/plugin-workspace-diff",
pluginKey: "paperclip.workspace-diff",
displayName: "Workspace Changes",
description: "First-party workspace Changes tab backed by plugin-local Git diff computation.",
localPath: "packages/plugins/plugin-workspace-diff",
tag: "first-party",
},
{ {
packageName: "@paperclipai/plugin-hello-world-example", packageName: "@paperclipai/plugin-hello-world-example",
pluginKey: "paperclip.hello-world-example", pluginKey: "paperclip.hello-world-example",
@@ -55,6 +55,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"routines.managed.reset": ["routines.managed"], "routines.managed.reset": ["routines.managed"],
"project.workspaces.list": ["project.workspaces.read"], "project.workspaces.list": ["project.workspaces.read"],
"project.workspaces.get": ["project.workspaces.read"], "project.workspaces.get": ["project.workspaces.read"],
"execution.workspaces.get": ["execution.workspaces.read"],
"issues.list": ["issues.read"], "issues.list": ["issues.read"],
"issues.get": ["issues.read"], "issues.get": ["issues.read"],
"issues.relations.get": ["issue.relations.read"], "issues.relations.get": ["issue.relations.read"],
@@ -20,12 +20,14 @@ import type {
IssueComment, IssueComment,
PluginIssueAssigneeSummary, PluginIssueAssigneeSummary,
PluginIssueOrchestrationSummary, PluginIssueOrchestrationSummary,
PluginExecutionWorkspaceMetadata,
} from "@paperclipai/plugin-sdk"; } from "@paperclipai/plugin-sdk";
import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared"; import type { CreateIssueThreadInteraction, IssueDocumentSummary } from "@paperclipai/shared";
import { pluginOperationIssueOriginKind } from "@paperclipai/shared"; import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js"; import { companyService } from "./companies.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { projectService } from "./projects.js"; import { projectService } from "./projects.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { issueService } from "./issues.js"; import { issueService } from "./issues.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js"; import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { goalService } from "./goals.js"; import { goalService } from "./goals.js";
@@ -520,6 +522,7 @@ export function buildHostServices(
pluginWorkerManager: options.pluginWorkerManager, pluginWorkerManager: options.pluginWorkerManager,
}); });
const projects = projectService(db); const projects = projectService(db);
const executionWorkspaces = executionWorkspaceService(db);
const issues = issueService(db); const issues = issueService(db);
const documents = documentService(db); const documents = documentService(db);
const goals = goalService(db); const goals = goalService(db);
@@ -588,6 +591,35 @@ export function buildHostServices(
companyId: string, companyId: string,
): record is T => Boolean(record && record.companyId === companyId); ): record is T => Boolean(record && record.companyId === companyId);
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
const readProviderMetadata = (metadata: Record<string, unknown> | null | undefined) => {
if (!isRecord(metadata)) return null;
if (isRecord(metadata.providerMetadata)) return { ...metadata.providerMetadata };
const rebuild = metadata.rebuild;
if (!isRecord(rebuild)) return null;
const rebuildMetadata = rebuild.metadata;
if (!isRecord(rebuildMetadata) || !isRecord(rebuildMetadata.providerMetadata)) return null;
return { ...rebuildMetadata.providerMetadata };
};
const toPluginExecutionWorkspaceMetadata = (
workspace: NonNullable<Awaited<ReturnType<typeof executionWorkspaces.getById>>>,
): PluginExecutionWorkspaceMetadata => ({
id: workspace.id,
companyId: workspace.companyId,
projectId: workspace.projectId,
projectWorkspaceId: workspace.projectWorkspaceId,
path: workspace.cwd ?? workspace.providerRef,
cwd: workspace.cwd,
repoUrl: workspace.repoUrl,
baseRef: workspace.baseRef,
branchName: workspace.branchName,
providerType: workspace.providerType,
providerMetadata: readProviderMetadata(workspace.metadata),
});
const requireInCompany = <T extends { companyId: string | null | undefined }>( const requireInCompany = <T extends { companyId: string | null | undefined }>(
entityName: string, entityName: string,
record: T | null | undefined, record: T | null | undefined,
@@ -1116,6 +1148,9 @@ export function buildHostServices(
projectId: row.projectId, projectId: row.projectId,
name, name,
path, path,
repoUrl: row.repoUrl,
repoRef: row.repoRef,
defaultRef: row.defaultRef,
isPrimary: row.isPrimary, isPrimary: row.isPrimary,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
updatedAt: row.updatedAt.toISOString(), updatedAt: row.updatedAt.toISOString(),
@@ -1135,6 +1170,9 @@ export function buildHostServices(
projectId: project.id, projectId: project.id,
name, name,
path, path,
repoUrl: row?.repoUrl ?? project.codebase.repoUrl,
repoRef: row?.repoRef ?? project.codebase.repoRef,
defaultRef: row?.defaultRef ?? project.codebase.defaultRef,
isPrimary: true, isPrimary: true,
createdAt: (row?.createdAt ?? project.createdAt).toISOString(), createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
@@ -1158,6 +1196,9 @@ export function buildHostServices(
projectId: project.id, projectId: project.id,
name, name,
path, path,
repoUrl: row?.repoUrl ?? project.codebase.repoUrl,
repoRef: row?.repoRef ?? project.codebase.repoRef,
defaultRef: row?.defaultRef ?? project.codebase.defaultRef,
isPrimary: true, isPrimary: true,
createdAt: (row?.createdAt ?? project.createdAt).toISOString(), createdAt: (row?.createdAt ?? project.createdAt).toISOString(),
updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(), updatedAt: (row?.updatedAt ?? project.updatedAt).toISOString(),
@@ -1197,6 +1238,18 @@ export function buildHostServices(
}, },
}, },
executionWorkspaces: {
async get(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const workspace = await executionWorkspaces.getById(params.workspaceId);
if (inCompany(workspace, companyId)) {
return toPluginExecutionWorkspaceMetadata(workspace);
}
return null;
},
},
routines: { routines: {
async managedGet(params) { async managedGet(params) {
const companyId = ensureCompanyId(params.companyId); const companyId = ensureCompanyId(params.companyId);
+24
View File
@@ -2970,6 +2970,28 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
return result; return result;
} }
async function retireDoneLivenessRecoveryBlockers() {
const closedRecoveries = await db
.select()
.from(issues)
.where(
and(
eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation),
isNull(issues.hiddenAt),
inArray(issues.status, ["done", "cancelled"]),
),
);
let blockerRelationsRemoved = 0;
for (const recovery of closedRecoveries) {
if (await removeRecoveryBlockerFromSource(recovery)) {
blockerRelationsRemoved += 1;
}
}
return { blockerRelationsRemoved };
}
function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) { function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) {
const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)); const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS));
return Math.min( return Math.min(
@@ -3365,6 +3387,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
const now = new Date(); const now = new Date();
const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000);
const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings);
const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers();
const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings);
const result = { const result = {
findings: findings.length, findings: findings.length,
@@ -3379,6 +3402,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired, obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired,
obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped, obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped,
obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved, obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved,
doneRecoveryBlockerRelationsRemoved: doneRecoveryBlockerCleanup.blockerRelationsRemoved,
issueIds: [] as string[], issueIds: [] as string[],
escalationIssueIds: [] as string[], escalationIssueIds: [] as string[],
retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds, retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds,
+1
View File
@@ -26,4 +26,5 @@ describe("executionWorkspacesApi.listSummaries", () => {
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true", "/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
); );
}); });
}); });
+1 -1
View File
@@ -138,7 +138,7 @@ export interface AvailablePluginExample {
displayName: string; displayName: string;
description: string; description: string;
localPath: string; localPath: string;
tag: "example"; tag: "example" | "first-party";
} }
export interface PluginLocalFolderProblem { 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",
]);
});
});
+106 -12
View File
@@ -11,6 +11,7 @@ import { Tabs } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { CopyText } from "../components/CopyText"; import { CopyText } from "../components/CopyText";
import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog";
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
import { agentsApi } from "../api/agents"; import { agentsApi } from "../api/agents";
import { executionWorkspacesApi } from "../api/execution-workspaces"; import { executionWorkspacesApi } from "../api/execution-workspaces";
import { heartbeatsApi } from "../api/heartbeats"; import { heartbeatsApi } from "../api/heartbeats";
@@ -19,6 +20,7 @@ import { projectsApi } from "../api/projects";
import { routinesApi } from "../api/routines"; import { routinesApi } from "../api/routines";
import { IssuesList } from "../components/IssuesList"; import { IssuesList } from "../components/IssuesList";
import { PageTabBar } from "../components/PageTabBar"; import { PageTabBar } from "../components/PageTabBar";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import { import {
RoutineRunVariablesDialog, RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData, type RoutineRunDialogSubmitData,
@@ -54,9 +56,36 @@ type WorkspaceFormState = {
workspaceRuntime: string; 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 segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces"); const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null; if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
@@ -69,7 +98,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
return null; return null;
} }
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceTab) { function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) {
const segment = tab === "runtime_logs" ? "runtime-logs" : tab; const segment = tab === "runtime_logs" ? "runtime-logs" : tab;
return `/execution-workspaces/${workspaceId}/${segment}`; return `/execution-workspaces/${workspaceId}/${segment}`;
} }
@@ -536,7 +565,12 @@ export function ExecutionWorkspaceDetail() {
const [errorMessage, setErrorMessage] = useState<string | null>(null); const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = 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({ const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!), queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
@@ -580,6 +614,30 @@ export function ExecutionWorkspaceDetail() {
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null, () => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
[project, workspace?.projectWorkspaceId], [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 inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
const runtimeConfigSource = const runtimeConfigSource =
@@ -684,11 +742,23 @@ export function ExecutionWorkspaceDetail() {
}); });
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null; 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) { if (workspaceId && activeTab === null) {
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />; return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
} }
const handleTabChange = (tab: ExecutionWorkspaceTab) => { const handleTabChange = (tab: ExecutionWorkspaceTab) => {
if (isExecutionWorkspacePluginTab(tab)) {
navigate(`/execution-workspaces/${workspace.id}?tab=${encodeURIComponent(tab)}`);
return;
}
navigate(executionWorkspaceTabPath(workspace.id, tab)); navigate(executionWorkspaceTabPath(workspace.id, tab));
}; };
@@ -731,15 +801,18 @@ export function ExecutionWorkspaceDetail() {
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null} {runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</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)}> <Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar <PageTabBar
items={[ items={workspaceTabItems.map((item) => ({ value: item.value, label: item.label }))}
{ value: "issues", label: "Issues" },
{ value: "services", label: "Services" },
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
]}
align="start" align="start"
value={activeTab ?? "issues"} value={activeTab ?? "issues"}
onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}
@@ -1128,11 +1201,32 @@ export function ExecutionWorkspaceDetail() {
error={linkedIssuesQuery.error as Error | null} error={linkedIssuesQuery.error as Error | null}
project={project} 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 <ExecutionWorkspaceRoutinesList
workspace={workspace} workspace={workspace}
project={project} project={project}
/> />
) : (
<LegacyWorkspaceTabRedirect workspaceId={workspace.id} />
)} )}
</div> </div>
<ExecutionWorkspaceCloseDialog <ExecutionWorkspaceCloseDialog
+5 -5
View File
@@ -220,16 +220,16 @@ export function PluginManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" /> <FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2> <h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Examples</Badge> <Badge variant="outline">Bundled</Badge>
</div> </div>
{examplesQuery.isLoading ? ( {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 ? ( ) : 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 ? ( ) : examples.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground"> <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> </div>
) : ( ) : (
<ul className="divide-y rounded-md border bg-card"> <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="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{example.displayName}</span> <span className="font-medium">{example.displayName}</span>
<Badge variant="outline">Example</Badge> <Badge variant="outline">{example.tag === "first-party" ? "First-party" : "Example"}</Badge>
{installedPlugin ? ( {installedPlugin ? (
<Badge <Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"} 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 projects primary codebase workspace.");
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("orders project workspace plugin tabs against built-in tabs by slot order", async () => {
mockPluginSlotState.slots = [
pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }),
pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }),
pluginSlot({ id: "default-tab", displayName: "Default" }),
];
await render();
const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent);
expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]);
});
it("navigates plugin tabs with only the generic plugin tab parameter", async () => {
mockPluginSlotState.slots = [pluginSlot()];
await render();
await act(async () => {
(container.querySelector('[data-tab-value="plugin:paperclip.quality:quality-tab"]') as HTMLButtonElement).click();
});
expect(mockNavigate).toHaveBeenCalledWith(
"/projects/paperclip-app/workspaces/workspace-1?tab=plugin%3Apaperclip.quality%3Aquality-tab",
);
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("diffView"));
expect(mockNavigate).not.toHaveBeenCalledWith(expect.stringContaining("baseRef"));
});
it("does not treat the old changes tab query as a core plugin tab", async () => {
mockPluginSlotState.slots = [pluginSlot()];
mockRouteSearch.value = "?tab=changes&diffView=head&baseRef=origin%2Fmain";
await render();
expect(container.querySelector('[data-tab-value="changes"]')).toBeNull();
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).toContain("Project workspace");
});
it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => {
mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot";
await render();
expect(container.textContent).toContain("Workspace plugin tab is not available.");
expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe(
"Back to configuration",
);
expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull();
expect(container.textContent).not.toContain("Configure the concrete workspace");
expect(container.textContent).not.toContain("Workspace name");
});
it("shows loading and error states for plugin tab manifests", async () => {
mockPluginSlotState.isLoading = true;
mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab";
await render();
expect(container.textContent).toContain("Loading workspace plugin...");
act(() => root?.unmount());
root = null;
container.innerHTML = "";
vi.clearAllMocks();
mockProjectsApi.get.mockResolvedValue(project());
mockPluginSlotState.isLoading = false;
mockPluginSlotState.errorMessage = "Plugin manifest failed";
await render();
expect(container.textContent).toContain("Plugin manifest failed");
});
});
+139 -37
View File
@@ -1,12 +1,16 @@
import { useEffect, useMemo, useState } from "react"; import { 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 { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared"; import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react"; import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
import { ChoosePathButton } from "../components/PathInstructionsModal"; import { ChoosePathButton } from "../components/PathInstructionsModal";
import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder";
import { projectsApi } from "../api/projects"; import { projectsApi } from "../api/projects";
import { PageTabBar } from "../components/PageTabBar";
import { PluginSlotMount, usePluginSlots } from "@/plugins/slots";
import { import {
buildWorkspaceRuntimeControlSections, buildWorkspaceRuntimeControlSections,
WorkspaceRuntimeControls, WorkspaceRuntimeControls,
@@ -35,6 +39,36 @@ type WorkspaceFormState = {
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"]; type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; 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 }> = [ 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." }, { 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 { companies, selectedCompanyId, setSelectedCompanyId } = useCompany();
const { setBreadcrumbs } = useBreadcrumbs(); const { setBreadcrumbs } = useBreadcrumbs();
const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [form, setForm] = useState<WorkspaceFormState | null>(null); const [form, setForm] = useState<WorkspaceFormState | null>(null);
@@ -224,6 +259,7 @@ export function ProjectWorkspaceDetail() {
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const routeProjectRef = projectId ?? ""; const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? ""; const routeWorkspaceId = workspaceId ?? "";
const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]);
const routeCompanyId = useMemo(() => { const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null; if (!companyPrefix) return null;
@@ -247,6 +283,29 @@ export function ProjectWorkspaceDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef; const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]); const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState)); 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(() => { useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return; if (!project?.companyId || project.companyId === selectedCompanyId) return;
@@ -272,8 +331,8 @@ export function ProjectWorkspaceDetail() {
useEffect(() => { useEffect(() => {
if (!project) return; if (!project) return;
if (routeProjectRef === canonicalProjectRef) return; if (routeProjectRef === canonicalProjectRef) return;
navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true }); navigate(`${projectWorkspaceUrl(project, routeWorkspaceId)}${location.search}`, { replace: true });
}, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]); }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, navigate]);
const invalidateProject = () => { const invalidateProject = () => {
if (!project) return; if (!project) return;
@@ -363,6 +422,15 @@ export function ProjectWorkspaceDetail() {
}; };
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null; 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 ( return (
<div className="mx-auto max-w-5xl space-y-6"> <div className="mx-auto max-w-5xl space-y-6">
@@ -373,45 +441,53 @@ export function ProjectWorkspaceDetail() {
Back to workspaces Back to workspaces
</Link> </Link>
</Button> </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>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 space-y-2">
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
Project workspace
</div>
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</div>
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
<PageTabBar
items={tabItems.map((item) => ({ value: item.value, label: item.label }))}
align="start"
value={activeTab}
onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}
/>
</Tabs>
{activeTab === "configuration" ? (
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]"> <div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-2xl border border-border bg-card p-5"> <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"> <p className="max-w-2xl text-sm text-muted-foreground">
<div className="space-y-2"> Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground"> checkout behavior, default runtime services for child execution workspaces, and let you override setup
Project workspace or cleanup commands when one workspace needs special handling.
</div> </p>
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
<p className="max-w-2xl text-sm text-muted-foreground">
Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace
checkout behavior, default runtime services for child execution workspaces, and let you override setup
or cleanup commands when one workspace needs special handling.
</p>
</div>
{!workspace.isPrimary ? (
<Button
variant="outline"
className="w-full sm:w-auto"
disabled={setPrimaryWorkspace.isPending}
onClick={() => setPrimaryWorkspace.mutate()}
>
{setPrimaryWorkspace.isPending
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
: <Check className="mr-2 h-4 w-4" />}
Make primary
</Button>
) : (
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
<Sparkles className="h-4 w-4" />
This is the projects primary codebase workspace.
</div>
)}
</div>
<Separator className="my-5" /> <Separator className="my-5" />
@@ -643,6 +719,32 @@ export function ProjectWorkspaceDetail() {
</div> </div>
</div> </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> </div>
); );
} }
+19
View File
@@ -23,6 +23,7 @@ import {
type PluginBridgeContextValue, type PluginBridgeContextValue,
} from "./bridge"; } from "./bridge";
import { initPluginBridge } from "./bridge-init"; import { initPluginBridge } from "./bridge-init";
import { _createReactShimSourceForTests } from "./slots";
function clickEvent( function clickEvent(
overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {}, overrides: Partial<ReactMouseEvent<HTMLAnchorElement>> = {},
@@ -304,3 +305,21 @@ describe("plugin SDK markdown component bridge", () => {
}))).toContain("Run lint"); }))).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
View File
@@ -29,6 +29,7 @@ import {
type ReactNode, type ReactNode,
type ComponentType, type ComponentType,
} from "react"; } from "react";
import * as ReactModule from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { import type {
PluginLauncherDeclaration, PluginLauncherDeclaration,
@@ -244,24 +245,31 @@ function applyJsxRuntimeKey(
return { ...(props ?? {}), key }; 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 { function getShimBlobUrl(specifier: "react" | "react-dom" | "react-dom/client" | "react/jsx-runtime" | "sdk-ui"): string {
if (shimBlobUrls[specifier]) return shimBlobUrls[specifier]; if (shimBlobUrls[specifier]) return shimBlobUrls[specifier];
let source: string; let source: string;
switch (specifier) { switch (specifier) {
case "react": case "react":
source = ` source = createReactShimSource(ReactModule);
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 };
`;
break; break;
case "react/jsx-runtime": case "react/jsx-runtime":
source = ` source = `
@@ -900,4 +908,5 @@ export function _resetPluginModuleLoader(): void {
} }
export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey; export const _applyJsxRuntimeKeyForTests = applyJsxRuntimeKey;
export const _createReactShimSourceForTests = createReactShimSource;
export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers; export const _rewriteBareSpecifiersForTests = rewriteBareSpecifiers;