diff --git a/ui/src/pages/ProjectWorkspaceDetail.test.tsx b/ui/src/pages/ProjectWorkspaceDetail.test.tsx
new file mode 100644
index 00000000000..9ee2d5e22ed
--- /dev/null
+++ b/ui/src/pages/ProjectWorkspaceDetail.test.tsx
@@ -0,0 +1,235 @@
+// @vitest-environment jsdom
+
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import type { Project, ProjectWorkspace } from "@paperclipai/shared";
+import { act, type ReactNode } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { ProjectWorkspaceDetail } from "./ProjectWorkspaceDetail";
+
+(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
+
+const mockProjectsApi = vi.hoisted(() => ({
+ get: vi.fn(),
+ updateWorkspace: vi.fn(),
+ controlWorkspaceCommands: vi.fn(),
+}));
+const mockNavigate = vi.hoisted(() => vi.fn());
+const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
+const mockSetSelectedCompanyId = vi.hoisted(() => vi.fn());
+const mockUsePluginSlots = vi.hoisted(() => vi.fn());
+const mockPluginSlotMount = vi.hoisted(() => vi.fn());
+const mockRouteSearch = vi.hoisted(() => ({ value: "" }));
+
+vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
+
+vi.mock("@/lib/router", () => ({
+ Link: ({ children, to }: { children?: ReactNode; to: string }) => {children},
+ useLocation: () => ({
+ pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1",
+ search: mockRouteSearch.value,
+ hash: "",
+ state: null,
+ }),
+ useNavigate: () => mockNavigate,
+ useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }),
+}));
+
+vi.mock("../context/CompanyContext", () => ({
+ useCompany: () => ({
+ companies: [{ id: "company-1", issuePrefix: "PAP" }],
+ selectedCompanyId: "company-1",
+ setSelectedCompanyId: mockSetSelectedCompanyId,
+ }),
+}));
+vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
+vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
+vi.mock("../components/WorkspaceRuntimeControls", () => ({
+ buildWorkspaceRuntimeControlSections: () => [],
+ WorkspaceRuntimeControls: () =>
,
+}));
+vi.mock("@/plugins/slots", () => ({
+ PluginSlotMount: (props: unknown) => {
+ mockPluginSlotMount(props);
+ return ;
+ },
+ usePluginSlots: (filters: unknown) => {
+ mockUsePluginSlots(filters);
+ return {
+ slots: [
+ {
+ id: "workspace-changes-tab",
+ type: "detailTab",
+ displayName: "Changes",
+ exportName: "WorkspaceChangesTab",
+ entityTypes: ["project_workspace"],
+ pluginId: "plugin-1",
+ pluginKey: "paperclip.workspace-diff",
+ pluginDisplayName: "Workspace Diff",
+ pluginVersion: "0.1.0",
+ },
+ ],
+ isLoading: false,
+ errorMessage: null,
+ };
+ },
+}));
+vi.mock("../components/PageTabBar", () => ({
+ PageTabBar: ({
+ items,
+ onValueChange,
+ }: {
+ items: Array<{ value: string; label: string }>;
+ onValueChange?: (value: string) => void;
+ }) => (
+
+ {items.map((item) => (
+
+ ))}
+
+ ),
+}));
+
+function projectWorkspace(overrides: Partial = {}): ProjectWorkspace {
+ const now = new Date("2026-05-01T00:00:00Z");
+ return {
+ id: "workspace-1",
+ companyId: "company-1",
+ projectId: "project-1",
+ name: "Primary checkout",
+ sourceType: "local_path",
+ cwd: "/tmp/paperclip",
+ repoUrl: "https://github.com/paperclipai/paperclip",
+ repoRef: "master",
+ defaultRef: "origin/main",
+ visibility: "default",
+ setupCommand: null,
+ cleanupCommand: null,
+ remoteProvider: null,
+ remoteWorkspaceRef: null,
+ sharedWorkspaceKey: null,
+ metadata: null,
+ runtimeConfig: null,
+ runtimeServices: [],
+ isPrimary: true,
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
+
+function project(overrides: Partial = {}): Project {
+ const now = new Date("2026-05-01T00:00:00Z");
+ const workspace = projectWorkspace();
+ return {
+ id: "project-1",
+ companyId: "company-1",
+ urlKey: "paperclip-app",
+ goalId: null,
+ goalIds: [],
+ goals: [],
+ name: "Paperclip App",
+ description: null,
+ status: "in_progress",
+ leadAgentId: null,
+ targetDate: null,
+ color: "#14b8a6",
+ env: null,
+ pauseReason: null,
+ pausedAt: null,
+ executionWorkspacePolicy: null,
+ codebase: {
+ workspaceId: workspace.id,
+ repoUrl: workspace.repoUrl,
+ repoRef: workspace.repoRef,
+ defaultRef: workspace.defaultRef,
+ repoName: "paperclip",
+ localFolder: workspace.cwd,
+ managedFolder: workspace.cwd ?? "/tmp/paperclip",
+ effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip",
+ origin: "local_folder",
+ },
+ workspaces: [workspace],
+ primaryWorkspace: workspace,
+ managedByPlugin: null,
+ archivedAt: null,
+ createdAt: now,
+ updatedAt: now,
+ ...overrides,
+ };
+}
+
+async function flush() {
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+describe("ProjectWorkspaceDetail changes tab", () => {
+ let root: Root | null = null;
+ let container: HTMLDivElement;
+
+ beforeEach(() => {
+ container = document.createElement("div");
+ document.body.appendChild(container);
+ mockProjectsApi.get.mockResolvedValue(project());
+ });
+
+ afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ container.remove();
+ vi.clearAllMocks();
+ mockRouteSearch.value = "";
+ });
+
+ async function render() {
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
+ await act(async () => {
+ root = createRoot(container);
+ root.render(
+
+
+ ,
+ );
+ });
+ await act(async () => {
+ await flush();
+ });
+ }
+
+ it("shows Changes as a first-class tab and maps the legacy plugin tab URL to it", async () => {
+ mockRouteSearch.value =
+ "?tab=plugin%3Apaperclip.workspace-diff%3Aworkspace-changes-tab&diffView=head&baseRef=origin%2Fmaster";
+
+ await render();
+
+ expect(container.querySelector('[data-tab-value="configuration"]')?.textContent).toBe("Configuration");
+ expect(container.querySelector('[data-tab-value="changes"]')?.textContent).toBe("Changes");
+ expect(container.querySelector('[data-testid="plugin-slot-mount"]')).not.toBeNull();
+ expect(mockPluginSlotMount).toHaveBeenCalledWith(
+ expect.objectContaining({
+ slot: expect.objectContaining({ pluginKey: "paperclip.workspace-diff", id: "workspace-changes-tab" }),
+ context: expect.objectContaining({ entityType: "project_workspace", entityId: "workspace-1" }),
+ }),
+ );
+ });
+
+ it("opens the Changes tab in upstream comparison mode", async () => {
+ await render();
+
+ await act(async () => {
+ (container.querySelector('[data-tab-value="changes"]') as HTMLButtonElement).click();
+ });
+
+ expect(mockNavigate).toHaveBeenCalledWith(
+ "/projects/paperclip-app/workspaces/workspace-1?tab=changes&diffView=head&baseRef=origin%2Fmain",
+ );
+ });
+});
diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx
index fb90e69d7d1..ac0d1c84f6e 100644
--- a/ui/src/pages/ProjectWorkspaceDetail.tsx
+++ b/ui/src/pages/ProjectWorkspaceDetail.tsx
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
import { Link, useLocation, useNavigate, useParams } from "@/lib/router";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isUuidLike, type ProjectWorkspace } from "@paperclipai/shared";
-import { ArrowLeft, Check, ExternalLink, GitCompare, Loader2, Sparkles } from "lucide-react";
+import { ArrowLeft, Check, ExternalLink, Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Tabs } from "@/components/ui/tabs";
@@ -38,13 +38,13 @@ type WorkspaceFormState = {
type ProjectWorkspaceSourceType = ProjectWorkspace["sourceType"];
type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"];
-type ProjectWorkspaceBaseTab = "configuration";
-type ProjectWorkspacePluginTab = `plugin:${string}`;
-type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab;
-const WORKSPACE_CHANGES_TAB = "plugin:paperclip.workspace-diff:workspace-changes-tab" as ProjectWorkspacePluginTab;
+type ProjectWorkspaceTab = "configuration" | "changes";
+const WORKSPACE_CHANGES_PLUGIN_TAB = "plugin:paperclip.workspace-diff:workspace-changes-tab";
-function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab {
- return typeof value === "string" && value.startsWith("plugin:");
+function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab {
+ const tab = new URLSearchParams(search).get("tab");
+ if (tab === "changes" || tab === WORKSPACE_CHANGES_PLUGIN_TAB) return "changes";
+ return "configuration";
}
function upstreamBaseRef(workspace: ProjectWorkspace) {
@@ -244,11 +244,7 @@ export function ProjectWorkspaceDetail() {
const [runtimeActionMessage, setRuntimeActionMessage] = useState(null);
const routeProjectRef = projectId ?? "";
const routeWorkspaceId = workspaceId ?? "";
- const pluginTabFromSearch = useMemo(() => {
- const tab = new URLSearchParams(location.search).get("tab");
- return isProjectWorkspacePluginTab(tab) ? tab : null;
- }, [location.search]);
- const activeTab: ProjectWorkspaceTab = pluginTabFromSearch ?? "configuration";
+ const activeTab = useMemo(() => projectWorkspaceTabFromSearch(location.search), [location.search]);
const routeCompanyId = useMemo(() => {
if (!companyPrefix) return null;
@@ -272,7 +268,11 @@ export function ProjectWorkspaceDetail() {
const canonicalProjectRef = project ? projectRouteRef(project) : routeProjectRef;
const initialState = useMemo(() => (workspace ? formStateFromWorkspace(workspace) : null), [workspace]);
const isDirty = Boolean(form && initialState && JSON.stringify(form) !== JSON.stringify(initialState));
- const { slots: pluginDetailSlots } = usePluginSlots({
+ const {
+ slots: pluginDetailSlots,
+ isLoading: pluginDetailSlotsLoading,
+ errorMessage: pluginDetailSlotsError,
+ } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "project_workspace",
companyId: project?.companyId ?? null,
@@ -280,13 +280,13 @@ export function ProjectWorkspaceDetail() {
});
const pluginTabItems = useMemo(
() => pluginDetailSlots.map((slot) => ({
- value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab,
+ value: `plugin:${slot.pluginKey}:${slot.id}`,
label: slot.displayName,
slot,
})),
[pluginDetailSlots],
);
- const activePluginTab = pluginTabItems.find((item) => item.value === activeTab) ?? null;
+ const changesPluginTab = pluginTabItems.find((item) => item.value === WORKSPACE_CHANGES_PLUGIN_TAB) ?? null;
useEffect(() => {
if (!project?.companyId || project.companyId === selectedCompanyId) return;
@@ -312,8 +312,8 @@ export function ProjectWorkspaceDetail() {
useEffect(() => {
if (!project) return;
if (routeProjectRef === canonicalProjectRef) return;
- navigate(projectWorkspaceUrl(project, routeWorkspaceId), { replace: true });
- }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, navigate]);
+ navigate(`${projectWorkspaceUrl(project, routeWorkspaceId)}${location.search}`, { replace: true });
+ }, [project, routeProjectRef, canonicalProjectRef, routeWorkspaceId, location.search, navigate]);
const invalidateProject = () => {
if (!project) return;
@@ -403,18 +403,15 @@ export function ProjectWorkspaceDetail() {
};
const sourceTypeDescription = SOURCE_TYPE_OPTIONS.find((option) => option.value === form.sourceType)?.description ?? null;
- const changesVsUpstreamUrl = (() => {
- const params = new URLSearchParams({
- tab: WORKSPACE_CHANGES_TAB,
- diffView: "head",
- baseRef: upstreamBaseRef(workspace),
- });
- return `${projectWorkspaceUrl(project, routeWorkspaceId)}?${params.toString()}`;
- })();
const handleTabChange = (tab: ProjectWorkspaceTab) => {
const workspacePath = projectWorkspaceUrl(project, routeWorkspaceId);
- if (isProjectWorkspacePluginTab(tab)) {
- navigate(`${workspacePath}?tab=${encodeURIComponent(tab)}`);
+ if (tab === "changes") {
+ const params = new URLSearchParams({
+ tab: "changes",
+ diffView: "head",
+ baseRef: upstreamBaseRef(workspace),
+ });
+ navigate(`${workspacePath}?${params.toString()}`);
return;
}
navigate(workspacePath);
@@ -432,19 +429,13 @@ export function ProjectWorkspaceDetail() {
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
-
handleTabChange(value as ProjectWorkspaceTab)}>
({ value: item.value, label: item.label })),
+ { value: "changes", label: "Changes" },
]}
align="start"
value={activeTab}
@@ -720,18 +711,28 @@ export function ProjectWorkspaceDetail() {
) : null}
- {activePluginTab ? (
-
+ {activeTab === "changes" ? (
+ changesPluginTab ? (
+
+ ) : (
+
+ {pluginDetailSlotsError
+ ? `Workspace changes failed to load: ${pluginDetailSlotsError}`
+ : pluginDetailSlotsLoading
+ ? "Loading workspace changes..."
+ : "Workspace changes are not available."}
+
+ )
) : null}
);