Fix project workspace changes tab

This commit is contained in:
Dotta committed 2026-05-15 12:54:28 -05:00
1 parent f097fe4937
commit bf7460227d
2 files changed
+282 -46

No files matched your search

@@ -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 }) => <a href={to}>{children}</a>,
useLocation: () => ({
pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1",
search: mockRouteSearch.value,
hash: "",
state: null,
}),
useNavigate: () => mockNavigate,
useParams: () => ({ companyPrefix: "PAP", projectId: "paperclip-app", workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: mockSetSelectedCompanyId,
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../components/PathInstructionsModal", () => ({ ChoosePathButton: () => null }));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
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;
}) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button
key={item.value}
data-tab-value={item.value}
type="button"
onClick={() => onValueChange?.(item.value)}
>
{item.label}
</button>
))}
</div>
),
}));
function projectWorkspace(overrides: Partial<ProjectWorkspace> = {}): ProjectWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
name: "Primary checkout",
sourceType: "local_path",
cwd: "/tmp/paperclip",
repoUrl: "https://github.com/paperclipai/paperclip",
repoRef: "master",
defaultRef: "origin/main",
visibility: "default",
setupCommand: null,
cleanupCommand: null,
remoteProvider: null,
remoteWorkspaceRef: null,
sharedWorkspaceKey: null,
metadata: null,
runtimeConfig: null,
runtimeServices: [],
isPrimary: true,
createdAt: now,
updatedAt: now,
...overrides,
};
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
const workspace = projectWorkspace();
return {
id: "project-1",
companyId: "company-1",
urlKey: "paperclip-app",
goalId: null,
goalIds: [],
goals: [],
name: "Paperclip App",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: workspace.id,
repoUrl: workspace.repoUrl,
repoRef: workspace.repoRef,
defaultRef: workspace.defaultRef,
repoName: "paperclip",
localFolder: workspace.cwd,
managedFolder: workspace.cwd ?? "/tmp/paperclip",
effectiveLocalFolder: workspace.cwd ?? "/tmp/paperclip",
origin: "local_folder",
},
workspaces: [workspace],
primaryWorkspace: workspace,
managedByPlugin: null,
archivedAt: null,
createdAt: now,
updatedAt: now,
...overrides,
};
}
async function flush() {
await new Promise((resolve) => setTimeout(resolve, 0));
await new Promise((resolve) => setTimeout(resolve, 0));
}
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(
<QueryClientProvider client={queryClient}>
<ProjectWorkspaceDetail />
</QueryClientProvider>,
);
});
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",
);
});
});
+47 -46
View File
@@ -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<string | null>(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() {
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
</div>
<Button variant="outline" size="sm" asChild>
<Link to={changesVsUpstreamUrl}>
<GitCompare className="mr-1 h-4 w-4" />
Changes vs upstream
</Link>
</Button>
</div>
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
<PageTabBar
items={[
{ value: "configuration", label: "Configuration" },
...pluginTabItems.map((item) => ({ value: item.value, label: item.label })),
{ value: "changes", label: "Changes" },
]}
align="start"
value={activeTab}
@@ -720,18 +711,28 @@ export function ProjectWorkspaceDetail() {
</div>
) : null}
{activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}
context={{
companyId: project.companyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
entityId: workspace.id,
entityType: "project_workspace",
}}
missingBehavior="placeholder"
/>
{activeTab === "changes" ? (
changesPluginTab ? (
<PluginSlotMount
slot={changesPluginTab.slot}
context={{
companyId: project.companyId,
companyPrefix: companyPrefix ?? null,
projectId: project.id,
entityId: workspace.id,
entityType: "project_workspace",
}}
missingBehavior="placeholder"
/>
) : (
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
{pluginDetailSlotsError
? `Workspace changes failed to load: ${pluginDetailSlotsError}`
: pluginDetailSlotsLoading
? "Loading workspace changes..."
: "Workspace changes are not available."}
</div>
)
) : null}
</div>
);