Add workspace changes plugin

This commit is contained in:
Dotta committed 2026-05-15 12:54:27 -05:00
1 parent 133366e216
commit 55bffc123a
41 files changed
+2881 -68

No files matched your search

@@ -0,0 +1,40 @@
{
"name": "@paperclipai/plugin-workspace-diff",
"version": "0.1.0",
"description": "First-party execution workspace Changes tab powered by Paperclip's host diff service",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts"
},
"paperclipPlugin": {
"manifest": "./dist/manifest.js",
"worker": "./dist/worker.js",
"ui": "./dist/ui/"
},
"scripts": {
"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",
"test": "vitest run"
},
"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,103 @@
import type {
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
} from "@paperclipai/plugin-sdk";
export type DiffRenderMode = "unified" | "split";
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"][];
patch: string | null;
}
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 function statusLabel(status: WorkspaceDiffFile["status"]) {
return STATUS_LABELS[status] ?? "Changed";
}
export function fileName(filePath: string) {
return filePath.split("/").filter(Boolean).pop() ?? filePath;
}
export function buildFilePatch(file: WorkspaceDiffFile): string | null {
const patches = file.patches
.map((patch) => patch.patch?.trimEnd() ?? "")
.filter(Boolean);
if (patches.length === 0) return null;
return patches.join("\n");
}
export function toFileViewModels(diff: WorkspaceDiffResponse | null | undefined): DiffFileViewModel[] {
return (diff?.files ?? []).map((file) => {
const patchWarnings = file.patches.flatMap((patch) => patch.warnings);
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, ...patchWarnings],
patchKinds: file.patches.map((patch) => patch.kind),
patch: buildFilePatch(file),
};
});
}
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)
+ (diff?.files ?? []).reduce((count, file) => {
return count + file.warnings.length + file.patches.reduce((patchCount, patch) => patchCount + patch.warnings.length, 0);
}, 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;
}
@@ -0,0 +1,2 @@
export { default as manifest } from "./manifest.js";
export { default as worker } from "./worker.js";
@@ -0,0 +1,36 @@
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 workspaces using Paperclip's host diff API and @pierre/diffs.",
author: "Paperclip",
categories: ["workspace", "ui"],
capabilities: [
"ui.detailTab.register",
"execution.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"],
order: 5,
},
],
},
};
export default manifest;
@@ -0,0 +1,321 @@
import type { PluginDetailTabProps } from "@paperclipai/plugin-sdk/ui";
import { usePluginData, usePluginToast } from "@paperclipai/plugin-sdk/ui";
import type { WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
import { PatchDiff } from "@pierre/diffs/react";
import { useEffect, useMemo, useState } from "react";
import {
diffSummary,
fileName,
nextExpandedFileSet,
statusLabel,
toFileViewModels,
type DiffFileViewModel,
type DiffRenderMode,
} from "../diff-model.js";
type WorkspaceDiffData = WorkspaceDiffResponse;
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.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);
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 className="flex min-w-0 items-start gap-2">
<button
type="button"
className="mt-0.5 text-muted-foreground hover:text-foreground"
onClick={onToggle}
title={expanded ? "Collapse file" : "Expand file"}
aria-label={expanded ? `Collapse ${file.path}` : `Expand ${file.path}`}
>
{expanded ? "−" : "+"}
</button>
<button
type="button"
className="min-w-0 flex-1 text-left"
onClick={onSelect}
>
<div className="truncate text-sm font-medium text-foreground">{fileName(file.path)}</div>
<div className="truncate font-mono text-[11px] text-muted-foreground">{file.path}</div>
</button>
<button
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 className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 pl-5 text-[11px] text-muted-foreground">
<span>{statusLabel(file.status)}</span>
<span className="font-mono text-emerald-700 dark:text-emerald-300">+{file.additions}</span>
<span className="font-mono text-red-700 dark:text-red-300">-{file.deletions}</span>
{warning ? <span className="text-amber-700 dark:text-amber-300">{warning}</span> : null}
</div>
</div>
);
}
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>
);
}
function ErrorState({ message }: { message: string }) {
return (
<div className="border border-destructive/30 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{message}
</div>
);
}
function FileDiffPanel({
file,
mode,
}: {
file: DiffFileViewModel;
mode: DiffRenderMode;
}) {
const warning = warningText(file);
if (warning || !file.patch) {
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="overflow-hidden border border-border bg-background">
<PatchDiff
patch={file.patch}
options={{
diffStyle: mode,
overflow: "scroll",
disableLineNumbers: false,
themeType: "system",
}}
/>
</div>
);
}
export function ChangesTab({ context }: PluginDetailTabProps) {
const toast = usePluginToast();
const [mode, setMode] = useState<DiffRenderMode>("split");
const [includeUntracked, setIncludeUntracked] = useState(true);
const [expandedFiles, setExpandedFiles] = useState<Set<string>>(() => new Set());
const [selectedPath, setSelectedPath] = useState<string | null>(null);
const params = useMemo(() => ({
workspaceId: context.entityId,
companyId: context.companyId ?? "",
view: "working-tree",
includeUntracked,
}), [context.companyId, context.entityId, includeUntracked]);
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;
useEffect(() => {
if (files.length === 0) {
setExpandedFiles(new Set());
setSelectedPath(null);
return;
}
setExpandedFiles((current) => current.size > 0 ? current : new Set(files.map((file) => file.path)));
setSelectedPath((current) => files.some((file) => file.path === current) ? current : files[0]?.path ?? null);
}, [files]);
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 className="flex flex-col gap-3 border-b border-border pb-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className="font-medium text-foreground">{summary.changedLabel}</span>
<span className="font-mono text-xs text-muted-foreground">{summary.lineLabel}</span>
{summary.truncated ? (
<span className="text-xs text-amber-700 dark:text-amber-300">Truncated</span>
) : null}
{summary.warningCount > 0 ? (
<span className="text-xs text-muted-foreground">{summary.warningCount} warnings</span>
) : null}
</div>
<div className="mt-1 truncate font-mono text-xs text-muted-foreground">
{data?.baseRef ? `base ${data.baseRef}` : "working tree"}{data?.headSha ? ` · ${data.headSha.slice(0, 12)}` : ""}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
<div className="inline-flex gap-1" aria-label="Diff layout">
<button type="button" className={buttonClass(mode === "split")} onClick={() => setMode("split")}>
Split
</button>
<button type="button" className={buttonClass(mode === "unified")} onClick={() => setMode("unified")}>
Unified
</button>
</div>
<button
type="button"
className={buttonClass(includeUntracked)}
onClick={() => setIncludeUntracked((value) => !value)}
>
Untracked
</button>
<button type="button" className={buttonClass(false)} onClick={() => refresh()}>
Refresh
</button>
</div>
</div>
{loading ? (
<LoadingState />
) : error ? (
<ErrorState message={error.message} />
) : files.length === 0 ? (
<EmptyState />
) : (
<div className="grid min-h-[560px] gap-3 lg:grid-cols-[280px_minmax(0,1fr)]">
<aside className="min-w-0 border border-border bg-background">
<div className="border-b border-border px-3 py-2 text-xs font-medium uppercase tracking-[0.14em] text-muted-foreground">
Files
</div>
<div className="max-h-[70vh] overflow-auto">
{files.map((file) => (
<FileRow
key={file.path}
file={file}
active={file.path === selectedFile?.path}
expanded={expandedFiles.has(file.path)}
onSelect={() => setSelectedPath(file.path)}
onToggle={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
onCopy={() => void copyPath(file.path)}
/>
))}
</div>
</aside>
<main className="min-w-0 space-y-3">
{files.map((file) => (
expandedFiles.has(file.path) ? (
<section
key={file.path}
className={file.path === selectedFile?.path ? "scroll-mt-20" : undefined}
>
<div className="flex min-w-0 items-center justify-between gap-3 border border-b-0 border-border bg-muted/35 px-3 py-2">
<button
type="button"
className="min-w-0 text-left"
onClick={() => setSelectedPath(file.path)}
>
<div className="truncate text-sm font-medium">{file.path}</div>
{file.oldPath ? (
<div className="truncate font-mono text-[11px] text-muted-foreground">
from {file.oldPath}
</div>
) : null}
</button>
<div className="flex shrink-0 items-center gap-1">
<button
type="button"
className={iconButtonClass(false)}
title="Copy path"
aria-label={`Copy ${file.path}`}
onClick={() => void copyPath(file.path)}
>
⧉
</button>
<button
type="button"
className={iconButtonClass(false)}
title="Collapse file"
aria-label={`Collapse ${file.path}`}
onClick={() => setExpandedFiles((current) => nextExpandedFileSet(current, file.path))}
>
−
</button>
</div>
</div>
<FileDiffPanel file={file} mode={mode} />
</section>
) : null
))}
</main>
</div>
)}
</div>
);
}
@@ -0,0 +1,58 @@
import { definePlugin, runWorker } from "@paperclipai/plugin-sdk";
import type { WorkspaceDiffQueryOptions } from "@paperclipai/plugin-sdk";
const PLUGIN_NAME = "workspace-diff";
function readString(value: unknown): string {
return typeof value === "string" ? value.trim() : "";
}
function readPaths(value: unknown): string[] {
if (!Array.isArray(value)) return [];
return value.flatMap((entry) => {
if (typeof entry !== "string") return [];
const trimmed = entry.trim();
return trimmed ? [trimmed] : [];
});
}
function readDiffOptions(params: Record<string, unknown>): Partial<WorkspaceDiffQueryOptions> {
const view = params.view === "head" ? "head" : "working-tree";
const baseRef = readString(params.baseRef) || null;
const includeUntracked = typeof params.includeUntracked === "boolean"
? params.includeUntracked
: true;
return {
view,
baseRef,
includeUntracked,
paths: readPaths(params.paths),
};
}
const plugin = definePlugin({
async setup(ctx) {
ctx.logger.info(`${PLUGIN_NAME} plugin setup`);
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");
}
return ctx.executionWorkspaces.getDiff(
workspaceId,
companyId,
readDiffOptions(params),
);
});
},
async onHealth() {
return { status: "ok", message: `${PLUGIN_NAME} ready` };
},
});
export default plugin;
runWorker(plugin, import.meta.url);
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import {
buildFilePatch,
diffSummary,
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"],
});
});
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 file = changedFile({
truncated: true,
warnings: [{ code: "patch_truncated", message: "Patch was truncated.", path: "src/app.ts" }],
patches: [],
});
const diff = diffResponse({ files: [file], truncated: true });
expect(buildFilePatch(file)).toBeNull();
expect(diffSummary(diff)).toMatchObject({
warningCount: 1,
truncated: true,
});
});
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,79 @@
import type { WorkspaceDiffFile, WorkspaceDiffResponse } from "@paperclipai/plugin-sdk";
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",
repoRoot: "/tmp/paperclip",
cwd: "/tmp/paperclip",
view: "working-tree",
baseRef: 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,76 @@
import { describe, expect, it, vi } from "vitest";
import { createTestHarness } from "@paperclipai/plugin-sdk/testing";
import manifest from "../src/manifest.js";
import plugin from "../src/worker.js";
import { diffResponse } from "./fixtures.js";
describe("workspace diff plugin", () => {
it("declares the execution workspace Changes tab and host diff capability", () => {
expect(manifest.capabilities).toContain("ui.detailTab.register");
expect(manifest.capabilities).toContain("execution.workspaces.read");
expect(manifest.ui?.slots).toContainEqual(expect.objectContaining({
type: "detailTab",
displayName: "Changes",
entityTypes: ["execution_workspace"],
}));
});
it("fetches changed workspace diffs through the host bridge client", async () => {
const harness = createTestHarness({ manifest });
const getDiff = vi.fn().mockResolvedValue(diffResponse());
harness.ctx.executionWorkspaces.getDiff = getDiff;
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(getDiff).toHaveBeenCalledWith("workspace-1", "company-1", {
view: "working-tree",
baseRef: null,
includeUntracked: false,
paths: ["src/app.ts"],
});
expect(result).toMatchObject({
stats: { fileCount: 1 },
files: [expect.objectContaining({ path: "src/app.ts" })],
});
});
it("returns empty and truncated host responses without fabricating filesystem data", async () => {
const harness = createTestHarness({ manifest });
const empty = diffResponse({ files: [] });
const truncated = diffResponse({
files: [],
truncated: true,
warnings: [{ code: "file_count_truncated", message: "Too many files.", path: null }],
});
const getDiff = vi.fn()
.mockResolvedValueOnce(empty)
.mockResolvedValueOnce(truncated);
harness.ctx.executionWorkspaces.getDiff = getDiff;
await plugin.definition.setup(harness.ctx);
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
})).resolves.toMatchObject({ files: [], truncated: false });
await expect(harness.getData("workspace-diff", {
workspaceId: "workspace-1",
companyId: "company-1",
})).resolves.toMatchObject({ files: [], truncated: true });
});
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,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": "."
},
"include": ["src", "tests"],
"exclude": ["dist", "node_modules"]
}
@@ -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]>;
};
/** Provides `executionWorkspaces.getDiff`. */
executionWorkspaces: {
getDiff(params: WorkerToHostMethods["executionWorkspaces.getDiff"][0]): Promise<WorkerToHostMethods["executionWorkspaces.getDiff"][1]>;
};
/** Provides `routines.managed.*`. */
routines: {
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.getPrimaryWorkspace": "project.workspaces.read",
"projects.getWorkspaceForIssue": "project.workspaces.read",
"executionWorkspaces.getDiff": "execution.workspaces.read",
"projects.managed.get": "projects.managed",
"projects.managed.reconcile": "projects.managed",
"projects.managed.reset": "projects.managed",
@@ -608,6 +614,9 @@ export function createHostClientHandlers(
"projects.getWorkspaceForIssue": gated("projects.getWorkspaceForIssue", async (params) => {
return services.projects.getWorkspaceForIssue(params);
}),
"executionWorkspaces.getDiff": gated("executionWorkspaces.getDiff", async (params) => {
return services.executionWorkspaces.getDiff(params);
}),
"projects.managed.get": gated("projects.managed.get", async (params) => {
return services.projects.getManaged(params);
}),
+6
View File
@@ -197,6 +197,7 @@ export type {
PluginStateClient,
PluginEntitiesClient,
PluginProjectsClient,
PluginExecutionWorkspacesClient,
PluginSkillsClient,
PluginCompaniesClient,
PluginIssuesClient,
@@ -244,6 +245,11 @@ export type {
PluginEntityRecord,
PluginEntityQuery,
PluginWorkspace,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
Company,
Project,
Issue,
+10
View File
@@ -39,6 +39,8 @@ import type {
Agent,
Goal,
PluginLocalFolderDeclaration,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
} from "@paperclipai/shared";
export type { PluginLauncherRenderContextSnapshot } from "@paperclipai/shared";
@@ -777,6 +779,14 @@ export interface WorkerToHostMethods {
params: { issueId: string; companyId: string },
result: PluginWorkspace | null,
];
"executionWorkspaces.getDiff": [
params: {
workspaceId: string;
companyId: string;
options?: Partial<WorkspaceDiffQueryOptions>;
},
result: WorkspaceDiffResponse,
];
"projects.managed.get": [
params: { projectKey: string; companyId: string },
result: PluginManagedProjectResolution,
+36
View File
@@ -975,6 +975,42 @@ export function createTestHarness(options: TestHarnessOptions): TestHarness {
},
},
},
executionWorkspaces: {
async getDiff(workspaceId, companyId, options) {
requireCapability(manifest, capabilitySet, "execution.workspaces.read");
return {
workspaceId,
companyId,
repoRoot: "/tmp/paperclip-test",
cwd: "/tmp/paperclip-test",
view: options?.view ?? "working-tree",
baseRef: options?.baseRef ?? null,
headSha: null,
includeUntracked: options?.includeUntracked ?? true,
paths: options?.paths ?? [],
files: [],
stats: {
fileCount: 0,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
binaryFileCount: 0,
oversizedFileCount: 0,
truncatedFileCount: 0,
additions: 0,
deletions: 0,
},
warnings: [],
caps: {
maxFiles: 0,
maxFileBytes: 0,
maxPatchBytes: 0,
maxTotalPatchBytes: 0,
},
truncated: false,
};
},
},
routines: {
managed: {
async get(routineKey, companyId) {
+31
View File
@@ -39,6 +39,11 @@ import type {
RoutineRun,
Agent,
Goal,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -120,6 +125,11 @@ export type {
IssueSurfaceVisibility,
Agent,
Goal,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
} from "@paperclipai/shared";
// ---------------------------------------------------------------------------
@@ -818,6 +828,24 @@ export interface PluginProjectsClient {
};
}
/**
* `ctx.executionWorkspaces` — read execution workspace metadata and host-derived
* views such as source-control diffs.
*
* Requires `execution.workspaces.read`.
*/
export interface PluginExecutionWorkspacesClient {
/**
* Return a host-generated diff for an execution workspace. The host enforces
* company access and reads through Paperclip's workspace diff service.
*/
getDiff(
workspaceId: string,
companyId: string,
options?: Partial<WorkspaceDiffQueryOptions>,
): Promise<WorkspaceDiffResponse>;
}
/**
* `ctx.routines` — resolve and reconcile plugin-managed Paperclip routines.
*
@@ -1642,6 +1670,9 @@ export interface PluginContext {
/** Read project and workspace metadata. Requires `projects.read` / `project.workspaces.read`. */
projects: PluginProjectsClient;
/** Read execution workspace metadata and diffs. Requires `execution.workspaces.read`. */
executionWorkspaces: PluginExecutionWorkspacesClient;
/** Resolve and reconcile plugin-managed routines. Requires `routines.managed`. */
routines: PluginRoutinesClient;
@@ -657,6 +657,12 @@ export function startWorkerRpcHost(options: WorkerRpcHostOptions): WorkerRpcHost
},
},
executionWorkspaces: {
async getDiff(workspaceId: string, companyId: string, options = {}) {
return callHost("executionWorkspaces.getDiff", { workspaceId, companyId, options });
},
},
routines: {
managed: {
async get(routineKey: string, companyId: string) {
+1
View File
@@ -726,6 +726,7 @@ export const PLUGIN_CAPABILITIES = [
"companies.read",
"projects.read",
"project.workspaces.read",
"execution.workspaces.read",
"issues.read",
"issue.relations.read",
"issue.subtree.read",
+23
View File
@@ -356,6 +356,17 @@ export type {
WorkspaceOperation,
WorkspaceOperationPhase,
WorkspaceOperationStatus,
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffStats,
WorkspaceDiffView,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
WorkspaceRuntimeDesiredState,
WorkspaceRealizationRecord,
WorkspaceRealizationRequest,
@@ -825,6 +836,17 @@ export {
executionWorkspaceCloseLinkedIssueSchema,
executionWorkspaceCloseReadinessSchema,
executionWorkspaceCloseReadinessStateSchema,
workspaceDiffCapsSchema,
workspaceDiffFilePatchSchema,
workspaceDiffFileSchema,
workspaceDiffFileStatusSchema,
workspaceDiffPatchKindSchema,
workspaceDiffQuerySchema,
workspaceDiffResponseSchema,
workspaceDiffStatsSchema,
workspaceDiffViewSchema,
workspaceDiffWarningCodeSchema,
workspaceDiffWarningSchema,
issueDocumentFormatSchema,
issueDocumentKeySchema,
upsertIssueDocumentSchema,
@@ -851,6 +873,7 @@ export {
type CreateIssueWorkProduct,
type UpdateIssueWorkProduct,
type UpdateExecutionWorkspace,
type WorkspaceDiffQuery,
type IssueDocumentFormat,
type UpsertIssueDocument,
type RestoreIssueDocumentRevision,
+13
View File
@@ -130,6 +130,19 @@ export type {
ProjectExecutionWorkspaceDefaultMode,
IssueExecutionWorkspaceSettings,
} from "./workspace-runtime.js";
export type {
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffStats,
WorkspaceDiffView,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
} from "./workspace-diff.js";
export type {
WorkspaceOperation,
WorkspaceOperationPhase,
+103
View File
@@ -0,0 +1,103 @@
export type WorkspaceDiffView = "working-tree" | "head";
export type WorkspaceDiffFileStatus =
| "added"
| "modified"
| "deleted"
| "renamed"
| "copied"
| "type_changed"
| "untracked"
| "unknown";
export type WorkspaceDiffPatchKind = "staged" | "unstaged" | "head" | "untracked";
export type WorkspaceDiffWarningCode =
| "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"
| "workspace_path_invalid";
export interface WorkspaceDiffQueryOptions {
view: WorkspaceDiffView;
baseRef: string | null;
includeUntracked: boolean;
paths: string[];
}
export interface WorkspaceDiffWarning {
code: WorkspaceDiffWarningCode;
message: string;
path: string | null;
}
export interface WorkspaceDiffCaps {
maxFiles: number;
maxFileBytes: number;
maxPatchBytes: number;
maxTotalPatchBytes: number;
}
export interface WorkspaceDiffFilePatch {
kind: WorkspaceDiffPatchKind;
patch: string | null;
additions: number;
deletions: number;
binary: boolean;
oversized: boolean;
truncated: boolean;
warnings: WorkspaceDiffWarning[];
}
export interface WorkspaceDiffFile {
path: string;
oldPath: string | null;
status: WorkspaceDiffFileStatus;
staged: boolean;
unstaged: boolean;
untracked: boolean;
binary: boolean;
oversized: boolean;
truncated: boolean;
additions: number;
deletions: number;
sizeBytes: number | null;
patches: WorkspaceDiffFilePatch[];
warnings: WorkspaceDiffWarning[];
}
export interface WorkspaceDiffStats {
fileCount: number;
stagedFileCount: number;
unstagedFileCount: number;
untrackedFileCount: number;
binaryFileCount: number;
oversizedFileCount: number;
truncatedFileCount: number;
additions: number;
deletions: number;
}
export interface WorkspaceDiffResponse {
workspaceId: string;
companyId: string;
repoRoot: string;
cwd: string;
view: WorkspaceDiffView;
baseRef: string | null;
headSha: string | null;
includeUntracked: boolean;
paths: string[];
files: WorkspaceDiffFile[];
stats: WorkspaceDiffStats;
warnings: WorkspaceDiffWarning[];
caps: WorkspaceDiffCaps;
truncated: boolean;
}
+15
View File
@@ -265,6 +265,21 @@ export {
type UpdateExecutionWorkspace,
} from "./execution-workspace.js";
export {
workspaceDiffCapsSchema,
workspaceDiffFilePatchSchema,
workspaceDiffFileSchema,
workspaceDiffFileStatusSchema,
workspaceDiffPatchKindSchema,
workspaceDiffQuerySchema,
workspaceDiffResponseSchema,
workspaceDiffStatsSchema,
workspaceDiffViewSchema,
workspaceDiffWarningCodeSchema,
workspaceDiffWarningSchema,
type WorkspaceDiffQuery,
} from "./workspace-diff.js";
export {
createGoalSchema,
updateGoalSchema,
@@ -0,0 +1,78 @@
import { describe, expect, it } from "vitest";
import { workspaceDiffQuerySchema, workspaceDiffResponseSchema } from "./workspace-diff.js";
describe("workspace diff validators", () => {
it("normalizes query options from route query strings", () => {
expect(workspaceDiffQuerySchema.parse({
view: "head",
baseRef: " main ",
includeUntracked: "false",
path: ["server/src/index.ts", "ui/src/App.tsx,packages/shared/src/index.ts"],
})).toEqual({
view: "head",
baseRef: "main",
includeUntracked: false,
paths: ["server/src/index.ts", "ui/src/App.tsx", "packages/shared/src/index.ts"],
});
});
it("validates the response contract", () => {
const response = workspaceDiffResponseSchema.parse({
workspaceId: "11111111-1111-4111-8111-111111111111",
companyId: "22222222-2222-4222-8222-222222222222",
repoRoot: "/tmp/repo",
cwd: "/tmp/repo",
view: "working-tree",
baseRef: null,
headSha: "abc123",
includeUntracked: true,
paths: [],
files: [{
path: "README.md",
oldPath: null,
status: "modified",
staged: true,
unstaged: false,
untracked: false,
binary: false,
oversized: false,
truncated: false,
additions: 1,
deletions: 0,
sizeBytes: 12,
patches: [{
kind: "staged",
patch: "diff --git a/README.md b/README.md\n",
additions: 1,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings: [],
}],
warnings: [],
}],
stats: {
fileCount: 1,
stagedFileCount: 1,
unstagedFileCount: 0,
untrackedFileCount: 0,
binaryFileCount: 0,
oversizedFileCount: 0,
truncatedFileCount: 0,
additions: 1,
deletions: 0,
},
warnings: [],
caps: {
maxFiles: 200,
maxFileBytes: 524288,
maxPatchBytes: 262144,
maxTotalPatchBytes: 1048576,
},
truncated: false,
});
expect(response.files[0]?.patches[0]?.kind).toBe("staged");
});
});
@@ -0,0 +1,134 @@
import { z } from "zod";
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",
"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((path) => path.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().uuid(),
companyId: z.string().uuid(),
repoRoot: z.string(),
cwd: z.string(),
view: workspaceDiffViewSchema,
baseRef: 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 WorkspaceDiffQuery = z.infer<typeof workspaceDiffQuerySchema>;
@@ -12,6 +12,10 @@ const mockExecutionWorkspaceService = vi.hoisted(() => ({
update: vi.fn(),
}));
const mockWorkspaceDiffService = vi.hoisted(() => ({
getDiff: vi.fn(),
}));
const mockWorkspaceOperationService = vi.hoisted(() => ({
listForExecutionWorkspace: vi.fn(),
createRecorder: vi.fn(),
@@ -22,18 +26,19 @@ const mockLogActivity = vi.hoisted(() => vi.fn(async () => undefined));
vi.mock("../services/index.js", () => ({
executionWorkspaceService: () => mockExecutionWorkspaceService,
logActivity: mockLogActivity,
workspaceDiffService: () => mockWorkspaceDiffService,
workspaceOperationService: () => mockWorkspaceOperationService,
}));
function createApp() {
function createApp(companyIds = ["company-1"]) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
companyIds,
source: "session",
isInstanceAdmin: false,
};
next();
@@ -55,6 +60,38 @@ describe.sequential("execution workspace routes", () => {
projectWorkspaceId: null,
},
]);
mockExecutionWorkspaceService.getById.mockResolvedValue(null);
mockWorkspaceDiffService.getDiff.mockResolvedValue({
workspaceId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
repoRoot: "/tmp/repo",
cwd: "/tmp/repo",
view: "working-tree",
baseRef: null,
headSha: "abc123",
includeUntracked: true,
paths: [],
files: [],
stats: {
fileCount: 0,
stagedFileCount: 0,
unstagedFileCount: 0,
untrackedFileCount: 0,
binaryFileCount: 0,
oversizedFileCount: 0,
truncatedFileCount: 0,
additions: 0,
deletions: 0,
},
warnings: [],
caps: {
maxFiles: 200,
maxFileBytes: 524288,
maxPatchBytes: 262144,
maxTotalPatchBytes: 1048576,
},
truncated: false,
});
});
it("uses summary mode for lightweight workspace lookups", async () => {
@@ -79,4 +116,59 @@ describe.sequential("execution workspace routes", () => {
});
expect(mockExecutionWorkspaceService.list).not.toHaveBeenCalled();
});
it("parses diff query options after enforcing workspace company access", async () => {
const workspace = {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Feature workspace",
status: "active",
cwd: "/tmp/repo",
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerRef: "/tmp/repo",
derivedFromExecutionWorkspaceId: null,
lastUsedAt: new Date(),
openedAt: new Date(),
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: new Date(),
updatedAt: new Date(),
};
mockExecutionWorkspaceService.getById.mockResolvedValue(workspace);
const res = await request(createApp())
.get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff?view=head&baseRef=main&includeUntracked=false&path=server/src/index.ts&path=ui/src/App.tsx");
expect(res.status).toBe(200);
expect(mockWorkspaceDiffService.getDiff).toHaveBeenCalledWith(workspace, {
view: "head",
baseRef: "main",
includeUntracked: false,
paths: ["server/src/index.ts", "ui/src/App.tsx"],
});
});
it("denies diff access when the workspace belongs to another company", async () => {
mockExecutionWorkspaceService.getById.mockResolvedValue({
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-2",
});
const res = await request(createApp(["company-1"]))
.get("/api/execution-workspaces/11111111-1111-4111-8111-111111111111/diff");
expect(res.status).toBe(403);
expect(mockWorkspaceDiffService.getDiff).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,184 @@
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 { ExecutionWorkspace, WorkspaceDiffQueryOptions } from "@paperclipai/shared";
import { WORKSPACE_DIFF_CAPS, workspaceDiffService } from "../services/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-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<ExecutionWorkspace> = {}): ExecutionWorkspace {
const now = new Date();
return {
id: randomUUID(),
companyId: randomUUID(),
projectId: randomUUID(),
projectWorkspaceId: null,
sourceIssueId: null,
mode: "isolated_workspace",
strategyType: "git_worktree",
name: "Diff workspace",
status: "active",
cwd,
repoUrl: null,
baseRef: null,
branchName: "feature",
providerType: "git_worktree",
providerRef: cwd,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: now,
openedAt: now,
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
createdAt: now,
updatedAt: now,
...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("workspaceDiffService", () => {
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, "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("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("surfaces missing cwd, non-git, invalid base refs, and unsafe path filters as 422 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-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);
});
+15 -1
View File
@@ -6,11 +6,12 @@ import {
findWorkspaceCommandDefinition,
matchWorkspaceRuntimeServiceToCommand,
updateExecutionWorkspaceSchema,
workspaceDiffQuerySchema,
workspaceRuntimeControlTargetSchema,
} from "@paperclipai/shared";
import type { WorkspaceRuntimeDesiredState, WorkspaceRuntimeServiceStateMap } from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import { executionWorkspaceService, logActivity, workspaceOperationService } from "../services/index.js";
import { executionWorkspaceService, logActivity, workspaceDiffService, workspaceOperationService } from "../services/index.js";
import { mergeExecutionWorkspaceConfig, readExecutionWorkspaceConfig } from "../services/execution-workspaces.js";
import { parseProjectExecutionWorkspacePolicy } from "../services/execution-workspace-policy.js";
import { readProjectWorkspaceRuntimeConfig } from "../services/project-workspace-runtime-config.js";
@@ -36,6 +37,7 @@ const WORKSPACE_CONTROL_OUTPUT_MAX_CHARS = 256 * 1024;
export function executionWorkspaceRoutes(db: Db) {
const router = Router();
const svc = executionWorkspaceService(db);
const diffSvc = workspaceDiffService();
const workspaceOperationsSvc = workspaceOperationService(db);
router.get("/companies/:companyId/execution-workspaces", async (req, res) => {
@@ -65,6 +67,18 @@ export function executionWorkspaceRoutes(db: Db) {
res.json(workspace);
});
router.get("/execution-workspaces/:id/diff", async (req, res) => {
const id = req.params.id as string;
const workspace = await svc.getById(id);
if (!workspace) {
res.status(404).json({ error: "Execution workspace not found" });
return;
}
assertCompanyAccess(req, workspace.companyId);
const query = workspaceDiffQuerySchema.parse(req.query);
res.json(await diffSvc.getDiff(workspace, query));
});
router.get("/execution-workspaces/:id/close-readiness", async (req, res) => {
const id = req.params.id as string;
const workspace = await svc.getById(id);
+9 -1
View File
@@ -120,7 +120,7 @@ interface AvailablePluginExample {
displayName: string;
description: string;
localPath: string;
tag: "example";
tag: "example" | "first-party";
}
/** 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 BUNDLED_PLUGIN_EXAMPLES: AvailablePluginExample[] = [
{
packageName: "@paperclipai/plugin-workspace-diff",
pluginKey: "paperclip.workspace-diff",
displayName: "Workspace Changes",
description: "First-party execution workspace Changes tab backed by Paperclip's host diff API.",
localPath: "packages/plugins/plugin-workspace-diff",
tag: "first-party",
},
{
packageName: "@paperclipai/plugin-hello-world-example",
pluginKey: "paperclip.hello-world-example",
+1
View File
@@ -49,6 +49,7 @@ export { instanceSettingsService } from "./instance-settings.js";
export { companyPortabilityService } from "./company-portability.js";
export { environmentService } from "./environments.js";
export { executionWorkspaceService } from "./execution-workspaces.js";
export { workspaceDiffService } from "./workspace-diff.js";
export { workspaceOperationService } from "./workspace-operations.js";
export { workProductService } from "./work-products.js";
export { logActivity, type LogActivityInput } from "./activity-log.js";
@@ -55,6 +55,7 @@ const OPERATION_CAPABILITIES: Record<string, readonly PluginCapability[]> = {
"routines.managed.reset": ["routines.managed"],
"project.workspaces.list": ["project.workspaces.read"],
"project.workspaces.get": ["project.workspaces.read"],
"execution.workspaces.getDiff": ["execution.workspaces.read"],
"issues.list": ["issues.read"],
"issues.get": ["issues.read"],
"issues.relations.get": ["issue.relations.read"],
@@ -26,6 +26,7 @@ import { pluginOperationIssueOriginKind } from "@paperclipai/shared";
import { companyService } from "./companies.js";
import { agentService } from "./agents.js";
import { projectService } from "./projects.js";
import { executionWorkspaceService } from "./execution-workspaces.js";
import { issueService } from "./issues.js";
import { issueThreadInteractionService } from "./issue-thread-interactions.js";
import { goalService } from "./goals.js";
@@ -69,6 +70,7 @@ import { request as httpsRequest } from "node:https";
import { isIP } from "node:net";
import { logger } from "../middleware/logger.js";
import { getTelemetryClient } from "../telemetry.js";
import { workspaceDiffService } from "./workspace-diff.js";
// ---------------------------------------------------------------------------
// SSRF protection for plugin HTTP fetch
@@ -520,6 +522,8 @@ export function buildHostServices(
pluginWorkerManager: options.pluginWorkerManager,
});
const projects = projectService(db);
const executionWorkspaces = executionWorkspaceService(db);
const workspaceDiff = workspaceDiffService();
const issues = issueService(db);
const documents = documentService(db);
const goals = goalService(db);
@@ -1197,6 +1201,23 @@ export function buildHostServices(
},
},
executionWorkspaces: {
async getDiff(params) {
const companyId = ensureCompanyId(params.companyId);
await ensurePluginAvailableForCompany(companyId);
const workspace = await executionWorkspaces.getById(params.workspaceId);
if (!inCompany(workspace, companyId)) {
throw new Error("Execution workspace not found");
}
return workspaceDiff.getDiff(workspace, {
view: params.options?.view ?? "working-tree",
baseRef: params.options?.baseRef ?? null,
includeUntracked: params.options?.includeUntracked ?? true,
paths: params.options?.paths ?? [],
});
},
},
routines: {
async managedGet(params) {
const companyId = ensureCompanyId(params.companyId);
+691
View File
@@ -0,0 +1,691 @@
import { execFile } from "node:child_process";
import fs from "node:fs/promises";
import path from "node:path";
import { promisify } from "node:util";
import type {
ExecutionWorkspace,
WorkspaceDiffCaps,
WorkspaceDiffFile,
WorkspaceDiffFilePatch,
WorkspaceDiffFileStatus,
WorkspaceDiffPatchKind,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceDiffWarning,
WorkspaceDiffWarningCode,
} from "@paperclipai/shared";
import { unprocessable } from "../errors.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;
interface GitStatusEntry {
status: WorkspaceDiffFileStatus;
path: string;
oldPath: string | null;
}
type DiffScope = "staged" | "unstaged" | "head";
interface MutableWorkspaceDiffFile extends WorkspaceDiffFile {
patchScopes: DiffScope[];
}
interface PatchBudget {
totalPatchBytes: number;
}
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> = {}) {
return unprocessable(message, { code, ...details });
}
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: ExecutionWorkspace) {
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 target = path.resolve(repoRoot, filePath);
if (!isWithinDirectory(target, repoRoot)) return null;
try {
const stat = await fs.stat(target);
return stat.isFile() ? stat.size : null;
} catch {
return null;
}
}
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 hunkLines = lines.map((line) => `+${line}`).join("\n");
const lineCount = countAddedLines(content);
return [
`diff --git a/${filePath} b/${filePath}`,
"new file mode 100644",
"--- /dev/null",
`+++ b/${filePath}`,
`@@ -0,0 +1,${lineCount} @@`,
hunkLines,
"",
].join("\n");
}
async function buildUntrackedFilePatch(input: {
repoRoot: string;
filePath: string;
budget: PatchBudget;
}): Promise<WorkspaceDiffFilePatch> {
const warnings: WorkspaceDiffWarning[] = [];
const sizeBytes = await statWorkspaceFile(input.repoRoot, input.filePath);
if (sizeBytes === null) {
return {
kind: "untracked",
patch: null,
additions: 0,
deletions: 0,
binary: false,
oversized: false,
truncated: false,
warnings,
};
}
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,
};
}
const buffer = await fs.readFile(path.join(input.repoRoot, input.filePath));
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: ExecutionWorkspace) {
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: ExecutionWorkspace;
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: ExecutionWorkspace, 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,
repoRoot,
cwd,
view: query.view,
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),
};
},
};
}
+1
View File
@@ -45,6 +45,7 @@
"@paperclipai/adapter-pi-local": "workspace:*",
"@paperclipai/adapter-utils": "workspace:*",
"@paperclipai/shared": "workspace:*",
"@pierre/diffs": "^1.1.22",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.21",
+6 -1
View File
@@ -17,6 +17,7 @@ import { Issues } from "./pages/Issues";
import { Search } from "./pages/Search";
import { IssueDetail } from "./pages/IssueDetail";
import { IssueChatLongThreadPerf } from "./pages/IssueChatLongThreadPerf";
import { RunTranscriptUxLab } from "./pages/RunTranscriptUxLab";
import { Routines } from "./pages/Routines";
import { RoutineDetail } from "./pages/RoutineDetail";
import { UserProfile } from "./pages/UserProfile";
@@ -107,7 +108,10 @@ function boardRoutes() {
<Route path="issues/recent" element={<Navigate to="/issues" replace />} />
<Route path="issues/:issueId" element={<IssueDetail />} />
{import.meta.env.DEV ? (
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<>
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<Route path="tests/ux/diff-renderer" element={<RunTranscriptUxLab />} />
</>
) : null}
<Route path="routines" element={<Routines />} />
<Route path="routines/:routineId" element={<RoutineDetail />} />
@@ -276,6 +280,7 @@ export function App() {
<Route path="cli-auth/:id" element={<CliAuthPage />} />
<Route path="invite/:token" element={<InviteLandingPage />} />
<Route path="tests/perf/long-thread" element={<IssueChatLongThreadPerf />} />
<Route path="tests/ux/diff-renderer" element={<RunTranscriptUxLab />} />
<Route element={<CloudAccessGate />}>
<Route index element={<CompanyRootRedirect />} />
+13
View File
@@ -26,4 +26,17 @@ describe("executionWorkspacesApi.listSummaries", () => {
"/companies/company-1/execution-workspaces?projectId=project-1&reuseEligible=true&summary=true",
);
});
it("requests workspace diffs with query options", async () => {
await executionWorkspacesApi.getDiff("workspace-1", {
view: "head",
baseRef: "main",
includeUntracked: false,
paths: ["server/src/index.ts", "packages/shared/src/index.ts"],
});
expect(mockApi.get).toHaveBeenCalledWith(
"/execution-workspaces/workspace-1/diff?view=head&baseRef=main&includeUntracked=false&path=server%2Fsrc%2Findex.ts&path=packages%2Fshared%2Fsrc%2Findex.ts",
);
});
});
+15
View File
@@ -2,6 +2,8 @@ import type {
ExecutionWorkspace,
ExecutionWorkspaceSummary,
ExecutionWorkspaceCloseReadiness,
WorkspaceDiffQueryOptions,
WorkspaceDiffResponse,
WorkspaceOperation,
WorkspaceRuntimeControlTarget,
} from "@paperclipai/shared";
@@ -51,6 +53,19 @@ export const executionWorkspacesApi = {
return api.get<ExecutionWorkspace[]>(`/companies/${companyId}/execution-workspaces${qs ? `?${qs}` : ""}`);
},
get: (id: string) => api.get<ExecutionWorkspace>(`/execution-workspaces/${id}`),
getDiff: (id: string, options: Partial<WorkspaceDiffQueryOptions> = {}) => {
const params = new URLSearchParams();
if (options.view) params.set("view", options.view);
if (options.baseRef) params.set("baseRef", options.baseRef);
if (typeof options.includeUntracked === "boolean") {
params.set("includeUntracked", String(options.includeUntracked));
}
for (const filePath of options.paths ?? []) {
params.append("path", filePath);
}
const qs = params.toString();
return api.get<WorkspaceDiffResponse>(`/execution-workspaces/${id}/diff${qs ? `?${qs}` : ""}`);
},
getCloseReadiness: (id: string) =>
api.get<ExecutionWorkspaceCloseReadiness>(`/execution-workspaces/${id}/close-readiness`),
listWorkspaceOperations: (id: string) =>
+1 -1
View File
@@ -138,7 +138,7 @@ export interface AvailablePluginExample {
displayName: string;
description: string;
localPath: string;
tag: "example";
tag: "example" | "first-party";
}
export interface PluginLocalFolderProblem {
+276
View File
@@ -0,0 +1,276 @@
import { parsePatchFiles } from "@pierre/diffs";
import type { FileDiffMetadata } from "@pierre/diffs";
const smallDiffPatch = `diff --git a/src/pagination.ts b/src/pagination.ts
index 6c2a1f4..e91d2ac 100644
--- a/src/pagination.ts
+++ b/src/pagination.ts
@@ -1,8 +1,12 @@
export type PageRequest = {
limit: number;
+ cursor?: string;
offset: number;
};
export function buildOffset(offset: number, limit: number) {
- return { offset, limit };
+ return {
+ offset,
+ limit,
+ hasMore: limit > 0,
+ };
}
`;
const largeDiffPatch = `diff --git a/tmp/pierre-large-old.ts b/tmp/pierre-large-new.ts
index 6ea4ef1..9449233 100644
--- a/tmp/pierre-large-old.ts
+++ b/tmp/pierre-large-new.ts
@@ -1,160 +1,163 @@
export const value_001 = '1';
export const value_002 = '2';
-export const value_003 = '3';
+export const value_003 = '6';
export const value_004 = '4';
export const value_005 = '5';
-export const value_006 = '6';
+export const value_006 = '12';
export const value_007 = '7';
export const value_008 = '8';
-export const value_009 = '9';
+export const value_009 = '18';
export const value_010 = '10';
export const value_011 = '11';
-export const value_012 = '12';
+export const value_012 = '24';
export const value_013 = '13';
export const value_014 = '14';
-export const value_015 = '15';
+export const value_015 = '30';
export const value_016 = '16';
export const value_017 = '17';
-export const value_018 = '18';
+export const value_018 = '36';
export const value_019 = '19';
export const value_020 = '20';
+export function isEven(n: number) {
+ return n % 2 === 0;
+}
-export const value_021 = '21';
+export const value_021 = '42';
export const value_022 = '22';
export const value_023 = '23';
-export const value_024 = '24';
+export const value_024 = '48';
export const value_025 = '25';
export const value_026 = '26';
-export const value_027 = '27';
+export const value_027 = '54';
export const value_028 = '28';
export const value_029 = '29';
-export const value_030 = '30';
+export const value_030 = '60';
export const value_031 = '31';
export const value_032 = '32';
-export const value_033 = '33';
+export const value_033 = '66';
export const value_034 = '34';
export const value_035 = '35';
-export const value_036 = '36';
+export const value_036 = '72';
export const value_037 = '37';
export const value_038 = '38';
-export const value_039 = '39';
+export const value_039 = '78';
export const value_040 = '40';
export const value_041 = '41';
-export const value_042 = '42';
+export const value_042 = '84';
export const value_043 = '43';
export const value_044 = '44';
-export const value_045 = '45';
+export const value_045 = '90';
export const value_046 = '46';
export const value_047 = '47';
-export const value_048 = '48';
+export const value_048 = '96';
export const value_049 = '49';
export const value_050 = '50';
-export const value_051 = '51';
+export const value_051 = '102';
export const value_052 = '52';
export const value_053 = '53';
-export const value_054 = '54';
+export const value_054 = '108';
export const value_055 = '55';
export const value_056 = '56';
-export const value_057 = '57';
+export const value_057 = '114';
export const value_058 = '58';
export const value_059 = '59';
-export const value_060 = '60';
+export const value_060 = '120';
export const value_061 = '61';
export const value_062 = '62';
-export const value_063 = '63';
+export const value_063 = '126';
export const value_064 = '64';
export const value_065 = '65';
-export const value_066 = '66';
+export const value_066 = '132';
export const value_067 = '67';
export const value_068 = '68';
-export const value_069 = '69';
+export const value_069 = '138';
export const value_070 = '70';
export const value_071 = '71';
-export const value_072 = '72';
+export const value_072 = '144';
export const value_073 = '73';
export const value_074 = '74';
-export const value_075 = '75';
+export const value_075 = '150';
export const value_076 = '76';
export const value_077 = '77';
-export const value_078 = '78';
+export const value_078 = '156';
export const value_079 = '79';
export const value_080 = '80';
-export const value_081 = '81';
+export const value_081 = '162';
export const value_082 = '82';
export const value_083 = '83';
-export const value_084 = '84';
+export const value_084 = '168';
export const value_085 = '85';
export const value_086 = '86';
-export const value_087 = '87';
+export const value_087 = '174';
export const value_088 = '88';
export const value_089 = '89';
-export const value_090 = '90';
+export const value_090 = '180';
export const value_091 = '91';
export const value_092 = '92';
-export const value_093 = '93';
+export const value_093 = '186';
export const value_094 = '94';
export const value_095 = '95';
-export const value_096 = '96';
+export const value_096 = '192';
export const value_097 = '97';
export const value_098 = '98';
-export const value_099 = '99';
+export const value_099 = '198';
export const value_100 = '100';
export const value_101 = '101';
-export const value_102 = '102';
+export const value_102 = '204';
export const value_103 = '103';
export const value_104 = '104';
-export const value_105 = '105';
+export const value_105 = '210';
export const value_106 = '106';
export const value_107 = '107';
-export const value_108 = '108';
+export const value_108 = '216';
export const value_109 = '109';
export const value_110 = '110';
-export const value_111 = '111';
+export const value_111 = '222';
export const value_112 = '112';
export const value_113 = '113';
-export const value_114 = '114';
+export const value_114 = '228';
export const value_115 = '115';
export const value_116 = '116';
-export const value_117 = '117';
+export const value_117 = '234';
export const value_118 = '118';
export const value_119 = '119';
-export const value_120 = '120';
+export const value_120 = '240';
export const value_121 = '121';
export const value_122 = '122';
-export const value_123 = '123';
+export const value_123 = '246';
export const value_124 = '124';
export const value_125 = '125';
-export const value_126 = '126';
+export const value_126 = '252';
export const value_127 = '127';
export const value_128 = '128';
-export const value_129 = '129';
+export const value_129 = '258';
export const value_130 = '130';
export const value_131 = '131';
-export const value_132 = '132';
+export const value_132 = '264';
export const value_133 = '133';
export const value_134 = '134';
-export const value_135 = '135';
+export const value_135 = '270';
export const value_136 = '136';
export const value_137 = '137';
-export const value_138 = '138';
+export const value_138 = '276';
export const value_139 = '139';
export const value_140 = '140';
-export const value_141 = '141';
+export const value_141 = '282';
export const value_142 = '142';
export const value_143 = '143';
-export const value_144 = '144';
+export const value_144 = '288';
export const value_145 = '145';
export const value_146 = '146';
-export const value_147 = '147';
+export const value_147 = '294';
export const value_148 = '148';
export const value_149 = '149';
-export const value_150 = '150';
+export const value_150 = '300';
export const value_151 = '151';
export const value_152 = '152';
-export const value_153 = '153';
+export const value_153 = '306';
export const value_154 = '154';
export const value_155 = '155';
-export const value_156 = '156';
+export const value_156 = '312';
export const value_157 = '157';
export const value_158 = '158';
-export const value_159 = '159';
+export const value_159 = '318';
export const value_160 = '160';
`;
function parsePatchFilesOrEmpty(patch: string): FileDiffMetadata[] {
return parsePatchFiles(patch).flatMap((entry) => entry.files);
}
export type DiffRendererFixtureId = "small" | "large";
export interface DiffRendererFixture {
id: DiffRendererFixtureId;
label: string;
patch: string;
fileDiffs: FileDiffMetadata[];
}
export const DIFF_RENDERER_FIXTURES: DiffRendererFixture[] = [
{
id: "small",
label: "Small mixed edit fixture",
patch: smallDiffPatch,
fileDiffs: parsePatchFilesOrEmpty(smallDiffPatch),
},
{
id: "large",
label: "Large 160-line patch fixture",
patch: largeDiffPatch,
fileDiffs: parsePatchFilesOrEmpty(largeDiffPatch),
},
];
+5 -5
View File
@@ -220,16 +220,16 @@ export function PluginManager() {
<div className="flex items-center gap-2">
<FlaskConical className="h-5 w-5 text-muted-foreground" />
<h2 className="text-base font-semibold">Available Plugins</h2>
<Badge variant="outline">Examples</Badge>
<Badge variant="outline">Bundled</Badge>
</div>
{examplesQuery.isLoading ? (
<div className="text-sm text-muted-foreground">Loading bundled examples...</div>
<div className="text-sm text-muted-foreground">Loading bundled plugins...</div>
) : examplesQuery.error ? (
<div className="text-sm text-destructive">Failed to load bundled examples.</div>
<div className="text-sm text-destructive">Failed to load bundled plugins.</div>
) : examples.length === 0 ? (
<div className="rounded-md border border-dashed px-4 py-3 text-sm text-muted-foreground">
No bundled example plugins were found in this checkout.
No bundled plugins were found in this checkout.
</div>
) : (
<ul className="divide-y rounded-md border bg-card">
@@ -246,7 +246,7 @@ export function PluginManager() {
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{example.displayName}</span>
<Badge variant="outline">Example</Badge>
<Badge variant="outline">{example.tag === "first-party" ? "First-party" : "Example"}</Badge>
{installedPlugin ? (
<Badge
variant={installedPlugin.status === "ready" ? "default" : "secondary"}
+253 -56
View File
@@ -1,22 +1,30 @@
import { useState } from "react";
import { FileDiff, PatchDiff } from "@pierre/diffs/react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { cn, formatDateTime } from "../lib/utils";
import { DIFF_RENDERER_FIXTURES, type DiffRendererFixtureId } from "../fixtures/diffRendererFixtures";
import { Identity } from "../components/Identity";
import { StatusBadge } from "../components/StatusBadge";
import { RunTranscriptView, type TranscriptDensity, type TranscriptMode } from "../components/transcript/RunTranscriptView";
import { runTranscriptFixtureEntries, runTranscriptFixtureMeta } from "../fixtures/runTranscriptFixtures";
import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower } from "lucide-react";
import { ExternalLink, FlaskConical, LayoutPanelLeft, MonitorCog, PanelsTopLeft, RadioTower, GitCompare } from "lucide-react";
type SurfaceId = "detail" | "live" | "dashboard";
type SurfaceId = "detail" | "live" | "dashboard" | "diffs";
type DiffComponentMode = "patch" | "file";
type DiffStyle = "unified" | "split";
type DiffOverflow = "scroll" | "wrap";
type DiffThemeType = "system" | "light" | "dark";
const surfaceOptions: Array<{
type SurfaceOption = {
id: SurfaceId;
label: string;
eyebrow: string;
description: string;
icon: typeof LayoutPanelLeft;
}> = [
};
const surfaceOptions: SurfaceOption[] = [
{
id: "detail",
label: "Run Detail",
@@ -38,6 +46,13 @@ const surfaceOptions: Array<{
description: "The active-agents dashboard card, tuned for compact scanning while keeping the same transcript language.",
icon: PanelsTopLeft,
},
{
id: "diffs",
label: "Diff Renderer",
eyebrow: "@pierre/diffs",
description: "A spike harness for `PatchDiff` and `FileDiff` with realistic fixtures and rendering options.",
icon: GitCompare,
},
];
function previewEntries(surface: SurfaceId) {
@@ -160,7 +175,9 @@ function DashboardPreview({
<div className="flex items-center gap-2">
<span className={cn(
"inline-flex h-2.5 w-2.5 rounded-full",
streaming ? "bg-cyan-500 shadow-[0_0_0_6px_rgba(34,211,238,0.12)]" : "bg-muted-foreground/35",
streaming
? "bg-cyan-500 shadow-[0_0_0_6px_rgba(34,211,238,0.12)]"
: "bg-muted-foreground/35",
)} />
<Identity name={runTranscriptFixtureMeta.agentName} size="sm" />
</div>
@@ -190,12 +207,111 @@ function DashboardPreview({
);
}
function DiffRendererPreview({
fixtureId,
componentMode,
diffStyle,
overflow,
showLineNumbers,
disableBackground,
themeType,
}: {
fixtureId: DiffRendererFixtureId;
componentMode: DiffComponentMode;
diffStyle: DiffStyle;
overflow: DiffOverflow;
showLineNumbers: boolean;
disableBackground: boolean;
themeType: DiffThemeType;
}) {
const fixture = DIFF_RENDERER_FIXTURES.find((item) => item.id === fixtureId) ?? DIFF_RENDERER_FIXTURES[0];
const firstFileDiff = fixture.fileDiffs[0];
const options = {
diffStyle,
overflow,
disableLineNumbers: !showLineNumbers,
disableBackground,
themeType,
};
return (
<div className="overflow-hidden rounded-xl border border-border/70 bg-background/75 shadow-[0_24px_60px_rgba(15,23,42,0.08)]">
<div className="border-b border-border/60 bg-background/90 px-5 py-4">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="uppercase tracking-[0.18em] text-[10px]">
Diff Render
</Badge>
<span className="rounded-full border border-border/70 bg-background/70 px-2 py-1 text-[10px] text-muted-foreground">
{fixture.label}
</span>
</div>
<div className="mt-2 text-sm font-medium">
{componentMode === "patch" ? "PatchDiff" : "FileDiff"} • {diffStyle.toUpperCase()} • {fixture.fileDiffs.length} file(s)
</div>
</div>
<div className="max-h-[720px] overflow-hidden bg-[radial-gradient(circle_at_top_left,rgba(8,145,178,0.08),transparent_36%),radial-gradient(circle_at_bottom_right,rgba(245,158,11,0.10),transparent_28%)] p-5">
<div className="rounded-lg border border-border/70 bg-background/80 p-3">
{componentMode === "file" && !firstFileDiff ? (
<div className="rounded-lg border border-destructive/35 bg-destructive/10 px-3 py-2 text-sm text-destructive">
Parsed fixture did not produce a file diff payload for `FileDiff`.
</div>
) : componentMode === "patch" ? (
<PatchDiff patch={fixture.patch} options={options} />
) : (
<FileDiff fileDiff={firstFileDiff} options={options} />
)}
</div>
</div>
</div>
);
}
function ToggleButtonGroup<T extends string>({
selected,
options,
onSelect,
}: {
selected: T;
options: Array<{
id: T;
label: string;
}>;
onSelect: (next: T) => void;
}) {
return (
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
{options.map((option) => (
<button
key={option.id}
type="button"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
selected === option.id ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => onSelect(option.id)}
>
{option.label}
</button>
))}
</div>
);
}
export function RunTranscriptUxLab() {
const [selectedSurface, setSelectedSurface] = useState<SurfaceId>("detail");
const [detailMode, setDetailMode] = useState<TranscriptMode>("nice");
const [streaming, setStreaming] = useState(true);
const [density, setDensity] = useState<TranscriptDensity>("comfortable");
const [diffComponentMode, setDiffComponentMode] = useState<DiffComponentMode>("file");
const [diffFixture, setDiffFixture] = useState<DiffRendererFixtureId>(DIFF_RENDERER_FIXTURES[0]?.id ?? "small");
const [diffStyle, setDiffStyle] = useState<DiffStyle>("unified");
const [diffOverflow, setDiffOverflow] = useState<DiffOverflow>("scroll");
const [diffShowLineNumbers, setDiffShowLineNumbers] = useState(true);
const [diffDisableBackground, setDiffDisableBackground] = useState(false);
const [diffThemeType, setDiffThemeType] = useState<DiffThemeType>("system");
const selected = surfaceOptions.find((option) => option.id === selectedSurface) ?? surfaceOptions[0];
return (
@@ -210,7 +326,7 @@ export function RunTranscriptUxLab() {
</div>
<h1 className="mt-4 text-2xl font-semibold tracking-tight">Run Transcript Fixtures</h1>
<p className="mt-2 text-sm text-muted-foreground">
Built from a real Paperclip development run, then sanitized so no secrets, local paths, or environment details survive into the fixture.
Built from real Paperclip development data, with a focused spike surface for diff rendering controls.
</p>
</div>
@@ -261,58 +377,129 @@ export function RunTranscriptUxLab() {
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
</Badge>
<Badge variant="outline" className="rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]">
{runTranscriptFixtureMeta.issueIdentifier}
</Badge>
<div className="mb-5 flex flex-wrap items-center gap-2">
{selectedSurface === "diffs" ? (
<>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Controls
</span>
<ToggleButtonGroup<DiffComponentMode>
selected={diffComponentMode}
options={[
{ id: "patch", label: "PatchDiff" },
{ id: "file", label: "FileDiff" },
]}
onSelect={setDiffComponentMode}
/>
<ToggleButtonGroup<DiffRendererFixtureId>
selected={diffFixture}
options={DIFF_RENDERER_FIXTURES.map((fixture) => ({
id: fixture.id,
label: fixture.label,
}))}
onSelect={setDiffFixture}
/>
<ToggleButtonGroup<DiffStyle>
selected={diffStyle}
options={[
{ id: "unified", label: "Unified" },
{ id: "split", label: "Split" },
]}
onSelect={setDiffStyle}
/>
<ToggleButtonGroup<DiffOverflow>
selected={diffOverflow}
options={[{ id: "scroll", label: "Scroll" }, { id: "wrap", label: "Wrap" }]}
onSelect={setDiffOverflow}
/>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setDiffShowLineNumbers((value) => !value)}
>
{diffShowLineNumbers ? "Hide" : "Show"} line numbers
</Button>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setDiffDisableBackground((value) => !value)}
>
{diffDisableBackground ? "Enable" : "Disable"} background
</Button>
<ToggleButtonGroup<DiffThemeType>
selected={diffThemeType}
options={[
{ id: "system", label: "Auto" },
{ id: "light", label: "Light" },
{ id: "dark", label: "Dark" },
]}
onSelect={setDiffThemeType}
/>
</>
) : (
<>
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Controls
</span>
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
{(["nice", "raw"] as const).map((mode) => (
<button
key={mode}
type="button"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
detailMode === mode
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDetailMode(mode)}
>
{mode}
</button>
))}
</div>
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
{(["comfortable", "compact"] as const).map((nextDensity) => (
<button
key={nextDensity}
type="button"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
density === nextDensity
? "bg-accent text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDensity(nextDensity)}
>
{nextDensity}
</button>
))}
</div>
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setStreaming((value) => !value)}
>
{streaming ? "Show settled state" : "Show streaming state"}
</Button>
</>
)}
</div>
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
<span className="text-[11px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Controls
</span>
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
{(["nice", "raw"] as const).map((mode) => (
<button
key={mode}
type="button"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
detailMode === mode ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDetailMode(mode)}
>
{mode}
</button>
))}
</div>
<div className="inline-flex rounded-full border border-border/70 bg-background/80 p-1">
{(["comfortable", "compact"] as const).map((nextDensity) => (
<button
key={nextDensity}
type="button"
className={cn(
"rounded-full px-3 py-1 text-xs font-medium capitalize transition-colors",
density === nextDensity ? "bg-accent text-foreground" : "text-muted-foreground hover:text-foreground",
)}
onClick={() => setDensity(nextDensity)}
>
{nextDensity}
</button>
))}
</div>
<Button
<Badge
variant="outline"
size="sm"
className="rounded-full"
onClick={() => setStreaming((value) => !value)}
className="mb-0.5 flex rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]"
>
{streaming ? "Show settled state" : "Show streaming state"}
</Button>
Source run {runTranscriptFixtureMeta.sourceRunId.slice(0, 8)}
</Badge>
<Badge
variant="outline"
className="mb-0.5 rounded-full px-3 py-1 text-[10px] uppercase tracking-[0.18em]"
>
{runTranscriptFixtureMeta.issueIdentifier}
</Badge>
</div>
{selectedSurface === "detail" ? (
@@ -323,8 +510,18 @@ export function RunTranscriptUxLab() {
<div className={cn(density === "compact" && "max-w-4xl")}>
<LiveWidgetPreview streaming={streaming} mode={detailMode} density={density} />
</div>
) : (
) : selectedSurface === "dashboard" ? (
<DashboardPreview streaming={streaming} mode={detailMode} density={density} />
) : (
<DiffRendererPreview
fixtureId={diffFixture}
componentMode={diffComponentMode}
diffStyle={diffStyle}
overflow={diffOverflow}
showLineNumbers={diffShowLineNumbers}
disableBackground={diffDisableBackground}
themeType={diffThemeType}
/>
)}
</main>
</div>