From 1d26d33aa491cc72d67970ce8a0bb6bd90f8cf22 Mon Sep 17 00:00:00 2001 From: Dotta Date: Sat, 16 May 2026 09:23:39 -0500 Subject: [PATCH] Handle missing workspace plugin tabs Co-Authored-By: Paperclip --- .../MissingPluginTabPlaceholder.tsx | 23 ++++ .../pages/ExecutionWorkspaceDetail.test.tsx | 76 ++++++++++++- ui/src/pages/ExecutionWorkspaceDetail.tsx | 45 ++++++-- ui/src/pages/ProjectWorkspaceDetail.test.tsx | 31 +++++- ui/src/pages/ProjectWorkspaceDetail.tsx | 105 +++++++++++------- 5 files changed, 224 insertions(+), 56 deletions(-) create mode 100644 ui/src/components/MissingPluginTabPlaceholder.tsx diff --git a/ui/src/components/MissingPluginTabPlaceholder.tsx b/ui/src/components/MissingPluginTabPlaceholder.tsx new file mode 100644 index 00000000000..34965427b58 --- /dev/null +++ b/ui/src/components/MissingPluginTabPlaceholder.tsx @@ -0,0 +1,23 @@ +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; + +interface MissingPluginTabPlaceholderProps { + defaultTabHref: string; + defaultTabLabel: string; +} + +export function MissingPluginTabPlaceholder({ + defaultTabHref, + defaultTabLabel, +}: MissingPluginTabPlaceholderProps) { + return ( +
+
+

Workspace plugin tab is not available.

+ +
+
+ ); +} diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx index 7bb4ca748a5..b15fba0b0a1 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -25,6 +25,15 @@ const mockSetBreadcrumbs = vi.hoisted(() => vi.fn()); const mockUsePluginSlots = vi.hoisted(() => vi.fn()); const mockPluginSlotOutlet = vi.hoisted(() => vi.fn()); const mockPluginSlotMount = vi.hoisted(() => vi.fn()); +const mockPluginSlotState = vi.hoisted(() => ({ + slots: [] as unknown[], + isLoading: false, + errorMessage: null as string | null, +})); +const mockRouteLocation = vi.hoisted(() => ({ + pathname: "/execution-workspaces/workspace-1/issues", + search: "", +})); vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi })); vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); @@ -34,9 +43,11 @@ vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi })); vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi })); vi.mock("@/lib/router", () => ({ - Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, + Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => ( + {children} + ), Navigate: ({ to }: { to: string }) =>
{to}
, - useLocation: () => ({ pathname: "/execution-workspaces/workspace-1/issues", search: "", hash: "", state: null }), + useLocation: () => ({ ...mockRouteLocation, hash: "", state: null }), useNavigate: () => mockNavigate, useParams: () => ({ workspaceId: "workspace-1" }), })); @@ -62,7 +73,12 @@ vi.mock("@/plugins/slots", () => ({ }, usePluginSlots: (filters: unknown) => { mockUsePluginSlots(filters); - return { slots: [], isLoading: false, errorMessage: null }; + const entityType = (filters as { entityType?: string }).entityType; + return { + slots: entityType === "execution_workspace" ? mockPluginSlotState.slots : [], + isLoading: mockPluginSlotState.isLoading, + errorMessage: mockPluginSlotState.errorMessage, + }; }, })); @@ -164,6 +180,21 @@ function project(overrides: Partial = {}): Project { }; } +function pluginSlot(overrides: Record = {}) { + return { + id: "changes-tab", + type: "detailTab", + displayName: "Changes", + exportName: "ExecutionWorkspaceChangesTab", + entityTypes: ["execution_workspace"], + pluginId: "plugin-1", + pluginKey: "paperclip.workspace-diff", + pluginDisplayName: "Workspace Changes", + pluginVersion: "0.1.0", + ...overrides, + }; +} + async function flush() { await new Promise((resolve) => setTimeout(resolve, 0)); await new Promise((resolve) => setTimeout(resolve, 0)); @@ -183,6 +214,9 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { mockAgentsApi.list.mockResolvedValue([]); mockRoutinesApi.list.mockResolvedValue([]); mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]); + mockPluginSlotState.slots = []; + mockPluginSlotState.isLoading = false; + mockPluginSlotState.errorMessage = null; }); afterEach(() => { @@ -190,6 +224,8 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { root = null; container.remove(); vi.clearAllMocks(); + mockRouteLocation.pathname = "/execution-workspaces/workspace-1/issues"; + mockRouteLocation.search = ""; }); async function render() { @@ -248,4 +284,38 @@ describe("ExecutionWorkspaceDetail plugin slots", () => { expect(props.entityType).toBe("execution_workspace"); } }); + + it("shows a missing plugin placeholder instead of routines for stale plugin tab URLs", async () => { + mockRouteLocation.pathname = "/execution-workspaces/workspace-1"; + mockRouteLocation.search = "?tab=plugin%3Amissing%3Aslot"; + + await render(); + + expect(container.textContent).toContain("Workspace plugin tab is not available."); + expect(container.querySelector('a[href="/execution-workspaces/workspace-1/issues"]')?.textContent).toBe("Back to issues"); + expect(container.textContent).not.toContain("Workspace routines"); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + }); + + it("orders execution workspace plugin tabs against built-in tabs by slot order", async () => { + mockPluginSlotState.slots = [ + pluginSlot({ id: "default-tab", displayName: "Default" }), + pluginSlot({ id: "changes-tab", displayName: "Changes", order: 25 }), + pluginSlot({ id: "inspect-tab", displayName: "Inspect", order: 50 }), + ]; + + await render(); + + const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); + expect(tabLabels).toEqual([ + "Issues", + "Services", + "Changes", + "Configuration", + "Runtime logs", + "Inspect", + "Routines", + "Default", + ]); + }); }); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index aaacd07f560..e60848a1596 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -11,6 +11,7 @@ import { Tabs } from "@/components/ui/tabs"; import { Textarea } from "@/components/ui/textarea"; import { CopyText } from "../components/CopyText"; import { ExecutionWorkspaceCloseDialog } from "../components/ExecutionWorkspaceCloseDialog"; +import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { agentsApi } from "../api/agents"; import { executionWorkspacesApi } from "../api/execution-workspaces"; import { heartbeatsApi } from "../api/heartbeats"; @@ -58,11 +59,32 @@ type WorkspaceFormState = { type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines"; type ExecutionWorkspacePluginTab = `plugin:${string}`; type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab; +type OrderedExecutionWorkspaceTabItem = { + value: ExecutionWorkspaceTab; + label: string; + order: number; +}; + +const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; +const EXECUTION_WORKSPACE_BASE_TAB_ITEMS: OrderedExecutionWorkspaceTabItem[] = [ + { value: "issues", label: "Issues", order: 10 }, + { value: "services", label: "Services", order: 20 }, + { value: "configuration", label: "Configuration", order: 30 }, + { value: "runtime_logs", label: "Runtime logs", order: 40 }, + { value: "routines", label: "Routines", order: 60 }, +]; function isExecutionWorkspacePluginTab(value: string | null): value is ExecutionWorkspacePluginTab { return typeof value === "string" && value.startsWith("plugin:"); } +function orderExecutionWorkspaceTabItems(items: OrderedExecutionWorkspaceTabItem[]) { + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => left.item.order - right.item.order || left.index - right.index) + .map(({ item }) => item); +} + function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceBaseTab | null { const segments = pathname.split("/").filter(Boolean); const executionWorkspacesIndex = segments.indexOf("execution-workspaces"); @@ -607,10 +629,15 @@ export function ExecutionWorkspaceDetail() { () => workspacePluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}` as ExecutionWorkspacePluginTab, label: slot.displayName, + order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER, slot, })), [workspacePluginDetailSlots], ); + const workspaceTabItems = useMemo( + () => orderExecutionWorkspaceTabItems([...EXECUTION_WORKSPACE_BASE_TAB_ITEMS, ...workspacePluginTabItems]), + [workspacePluginTabItems], + ); const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null; const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig; const runtimeConfigSource = @@ -785,14 +812,7 @@ export function ExecutionWorkspaceDetail() { handleTabChange(value as ExecutionWorkspaceTab)}> ({ value: item.value, label: item.label })), - ]} + items={workspaceTabItems.map((item) => ({ value: item.value, label: item.label }))} align="start" value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)} @@ -1195,11 +1215,18 @@ export function ExecutionWorkspaceDetail() { {workspacePluginDetailSlotsError} - ) : ( + ) : isExecutionWorkspacePluginTab(activeTab) ? ( + + ) : activeTab === "routines" ? ( + ) : ( + )} ({ vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi })); vi.mock("@/lib/router", () => ({ - Link: ({ children, to }: { children?: ReactNode; to: string }) => {children}, + Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => ( + {children} + ), useLocation: () => ({ pathname: "/PAP/projects/paperclip-app/workspaces/workspace-1", search: mockRouteSearch.value, @@ -248,6 +250,19 @@ describe("ProjectWorkspaceDetail plugin tabs", () => { ); }); + it("orders project workspace plugin tabs against built-in tabs by slot order", async () => { + mockPluginSlotState.slots = [ + pluginSlot({ id: "late-tab", displayName: "Late", order: 40 }), + pluginSlot({ id: "early-tab", displayName: "Early", order: 20 }), + pluginSlot({ id: "default-tab", displayName: "Default" }), + ]; + + await render(); + + const tabLabels = Array.from(container.querySelectorAll("[data-tab-value]")).map((tab) => tab.textContent); + expect(tabLabels).toEqual(["Early", "Configuration", "Late", "Default"]); + }); + it("navigates plugin tabs with only the generic plugin tab parameter", async () => { mockPluginSlotState.slots = [pluginSlot()]; @@ -275,6 +290,20 @@ describe("ProjectWorkspaceDetail plugin tabs", () => { expect(container.textContent).toContain("Project workspace"); }); + it("shows a missing plugin placeholder instead of configuration for stale plugin tab URLs", async () => { + mockRouteSearch.value = "?tab=plugin%3Amissing%3Aslot"; + + await render(); + + expect(container.textContent).toContain("Workspace plugin tab is not available."); + expect(container.querySelector('a[href="/projects/paperclip-app/workspaces/workspace-1?tab=configuration"]')?.textContent).toBe( + "Back to configuration", + ); + expect(container.querySelector('[data-testid="plugin-slot-mount"]')).toBeNull(); + expect(container.textContent).not.toContain("Configure the concrete workspace"); + expect(container.textContent).not.toContain("Workspace name"); + }); + it("shows loading and error states for plugin tab manifests", async () => { mockPluginSlotState.isLoading = true; mockRouteSearch.value = "?tab=plugin%3Apaperclip.quality%3Aquality-tab"; diff --git a/ui/src/pages/ProjectWorkspaceDetail.tsx b/ui/src/pages/ProjectWorkspaceDetail.tsx index 90734950783..3d87bbad3e6 100644 --- a/ui/src/pages/ProjectWorkspaceDetail.tsx +++ b/ui/src/pages/ProjectWorkspaceDetail.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; import { Tabs } from "@/components/ui/tabs"; import { ChoosePathButton } from "../components/PathInstructionsModal"; +import { MissingPluginTabPlaceholder } from "../components/MissingPluginTabPlaceholder"; import { projectsApi } from "../api/projects"; import { PageTabBar } from "../components/PageTabBar"; import { PluginSlotMount, usePluginSlots } from "@/plugins/slots"; @@ -41,6 +42,16 @@ type ProjectWorkspaceVisibility = ProjectWorkspace["visibility"]; type ProjectWorkspaceBaseTab = "configuration"; type ProjectWorkspacePluginTab = `plugin:${string}`; type ProjectWorkspaceTab = ProjectWorkspaceBaseTab | ProjectWorkspacePluginTab; +type OrderedProjectWorkspaceTabItem = { + value: ProjectWorkspaceTab; + label: string; + order: number; +}; + +const DEFAULT_PLUGIN_DETAIL_TAB_ORDER = 100; +const PROJECT_WORKSPACE_BASE_TAB_ITEMS: OrderedProjectWorkspaceTabItem[] = [ + { value: "configuration", label: "Configuration", order: 30 }, +]; function isProjectWorkspacePluginTab(value: string | null): value is ProjectWorkspacePluginTab { return typeof value === "string" && value.startsWith("plugin:"); @@ -52,6 +63,13 @@ function projectWorkspaceTabFromSearch(search: string): ProjectWorkspaceTab { return "configuration"; } +function orderProjectWorkspaceTabItems(items: OrderedProjectWorkspaceTabItem[]) { + return items + .map((item, index) => ({ item, index })) + .sort((left, right) => left.item.order - right.item.order || left.index - right.index) + .map(({ item }) => item); +} + const SOURCE_TYPE_OPTIONS: Array<{ value: ProjectWorkspaceSourceType; label: string; description: string }> = [ { value: "local_path", label: "Local git checkout", description: "A local path Paperclip can use directly." }, { value: "non_git_path", label: "Local non-git path", description: "A local folder without git semantics." }, @@ -279,10 +297,15 @@ export function ProjectWorkspaceDetail() { () => pluginDetailSlots.map((slot) => ({ value: `plugin:${slot.pluginKey}:${slot.id}` as ProjectWorkspacePluginTab, label: slot.displayName, + order: slot.order ?? DEFAULT_PLUGIN_DETAIL_TAB_ORDER, slot, })), [pluginDetailSlots], ); + const tabItems = useMemo( + () => orderProjectWorkspaceTabItems([...PROJECT_WORKSPACE_BASE_TAB_ITEMS, ...pluginTabItems]), + [pluginTabItems], + ); useEffect(() => { if (!project?.companyId || project.companyId === selectedCompanyId) return; @@ -418,17 +441,38 @@ export function ProjectWorkspaceDetail() { Back to workspaces -
- {workspace.isPrimary ? "Primary workspace" : "Secondary workspace"} +
+ +
+
+
+ Project workspace +
+

{workspace.name}

+ {!workspace.isPrimary ? ( + + ) : ( +
+ + This is the project’s primary codebase workspace. +
+ )}
handleTabChange(value as ProjectWorkspaceTab)}> ({ value: item.value, label: item.label })), - ]} + items={tabItems.map((item) => ({ value: item.value, label: item.label }))} align="start" value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)} @@ -439,37 +483,11 @@ export function ProjectWorkspaceDetail() {
-
-
-
- Project workspace -
-

{workspace.name}

-

- Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace - checkout behavior, default runtime services for child execution workspaces, and let you override setup - or cleanup commands when one workspace needs special handling. -

-
- {!workspace.isPrimary ? ( - - ) : ( -
- - This is the project’s primary codebase workspace. -
- )} -
+

+ Configure the concrete workspace Paperclip attaches to this project. These values drive per-workspace + checkout behavior, default runtime services for child execution workspaces, and let you override setup + or cleanup commands when one workspace needs special handling. +

@@ -716,14 +734,15 @@ export function ProjectWorkspaceDetail() { }} missingBehavior="placeholder" /> - ) : ( + ) : pluginDetailSlotsLoading || pluginDetailSlotsError ? (
- {pluginDetailSlotsError - ? pluginDetailSlotsError - : pluginDetailSlotsLoading - ? "Loading workspace plugin..." - : "Workspace plugin tab is not available."} + {pluginDetailSlotsError ? pluginDetailSlotsError : "Loading workspace plugin..."}
+ ) : ( + ) ) : null}