From bf7460227db3d166835339e48bebf81c06759852 Mon Sep 17 00:00:00 2001 From: Dotta Date: Fri, 15 May 2026 11:15:03 -0500 Subject: [PATCH] Fix project workspace changes tab --- ui/src/pages/ProjectWorkspaceDetail.test.tsx | 235 +++++++++++++++++++ ui/src/pages/ProjectWorkspaceDetail.tsx | 93 ++++---- 2 files changed, 282 insertions(+), 46 deletions(-) create mode 100644 ui/src/pages/ProjectWorkspaceDetail.test.tsx 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} );