Add execution_workspace plugin UI slots (PAP-9423)

- Extend PLUGIN_UI_SLOT_ENTITY_TYPES with `execution_workspace`
- Mount toolbar/context plugin slots and dynamic detail-tab slots in
  ExecutionWorkspaceDetail with `entityType: "execution_workspace"`
  context (workspace id, project id, company id)
- Plugin tabs use the search-param navigation pattern used by
  ProjectDetail so router-known tabs keep their pathname segments
- Add focused validator + render tests covering entity-type scoping,
  toolbar context wiring, and that workspace slots stay scoped to
  the workspace entity type

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-05-15 12:54:03 -05:00
1 parent 4c47eb46c3
commit 133366e216
4 files changed
+335 -4

No files matched your search

+1
View File
@@ -961,6 +961,7 @@ export const PLUGIN_UI_SLOT_ENTITY_TYPES = [
"goal",
"run",
"comment",
"execution_workspace",
] as const;
export type PluginUiSlotEntityType = (typeof PLUGIN_UI_SLOT_ENTITY_TYPES)[number];
@@ -104,4 +104,28 @@ describe("plugin UI slot validators", () => {
if (parsed.success) return;
expect(parsed.error.issues.some((issue) => issue.message.includes("reserved by the host"))).toBe(true);
});
it("accepts execution_workspace as a detailTab entityType", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "detailTab",
id: "workspace-diff-viewer",
displayName: "Diff",
exportName: "WorkspaceDiffViewer",
entityTypes: ["execution_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
it("accepts execution_workspace as a toolbarButton entityType", () => {
const parsed = pluginUiSlotDeclarationSchema.parse({
type: "toolbarButton",
id: "workspace-open-diff",
displayName: "Open diff",
exportName: "OpenWorkspaceDiffButton",
entityTypes: ["execution_workspace"],
});
expect(parsed.entityTypes).toEqual(["execution_workspace"]);
});
});
@@ -0,0 +1,251 @@
// @vitest-environment jsdom
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ExecutionWorkspace, Project } 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 { ExecutionWorkspaceDetail } from "./ExecutionWorkspaceDetail";
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const mockExecutionWorkspacesApi = vi.hoisted(() => ({
get: vi.fn(),
update: vi.fn(),
listWorkspaceOperations: vi.fn(),
controlRuntimeCommands: vi.fn(),
}));
const mockProjectsApi = vi.hoisted(() => ({ get: vi.fn() }));
const mockIssuesApi = vi.hoisted(() => ({ get: vi.fn(), list: vi.fn() }));
const mockAgentsApi = vi.hoisted(() => ({ list: vi.fn() }));
const mockHeartbeatsApi = vi.hoisted(() => ({ liveRunsForCompany: vi.fn() }));
const mockRoutinesApi = vi.hoisted(() => ({ list: vi.fn(), get: vi.fn(), run: vi.fn() }));
const mockNavigate = vi.hoisted(() => vi.fn());
const mockSetBreadcrumbs = vi.hoisted(() => vi.fn());
const mockUsePluginSlots = vi.hoisted(() => vi.fn());
const mockPluginSlotOutlet = vi.hoisted(() => vi.fn());
const mockPluginSlotMount = vi.hoisted(() => vi.fn());
vi.mock("../api/execution-workspaces", () => ({ executionWorkspacesApi: mockExecutionWorkspacesApi }));
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
vi.mock("../api/issues", () => ({ issuesApi: mockIssuesApi }));
vi.mock("../api/agents", () => ({ agentsApi: mockAgentsApi }));
vi.mock("../api/heartbeats", () => ({ heartbeatsApi: mockHeartbeatsApi }));
vi.mock("../api/routines", () => ({ routinesApi: mockRoutinesApi }));
vi.mock("@/lib/router", () => ({
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
useLocation: () => ({ pathname: "/execution-workspaces/workspace-1/issues", search: "", hash: "", state: null }),
useNavigate: () => mockNavigate,
useParams: () => ({ workspaceId: "workspace-1" }),
}));
vi.mock("../context/CompanyContext", () => ({
useCompany: () => ({
companies: [{ id: "company-1", issuePrefix: "PAP" }],
selectedCompanyId: "company-1",
setSelectedCompanyId: vi.fn(),
}),
}));
vi.mock("../context/BreadcrumbContext", () => ({ useBreadcrumbs: () => ({ setBreadcrumbs: mockSetBreadcrumbs }) }));
vi.mock("../context/ToastContext", () => ({ useToastActions: () => ({ pushToast: vi.fn() }) }));
vi.mock("@/plugins/slots", () => ({
PluginSlotMount: (props: unknown) => {
mockPluginSlotMount(props);
return <div data-testid="plugin-slot-mount" />;
},
PluginSlotOutlet: (props: unknown) => {
mockPluginSlotOutlet(props);
return <div data-testid="plugin-slot-outlet" />;
},
usePluginSlots: (filters: unknown) => {
mockUsePluginSlots(filters);
return { slots: [], isLoading: false, errorMessage: null };
},
}));
vi.mock("../components/IssuesList", () => ({
IssuesList: () => <div data-testid="issues-list" />,
}));
vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({
ExecutionWorkspaceCloseDialog: () => null,
}));
vi.mock("../components/RoutineRunVariablesDialog", () => ({
RoutineRunVariablesDialog: () => null,
}));
vi.mock("../components/WorkspaceRuntimeControls", () => ({
buildWorkspaceRuntimeControlSections: () => [],
WorkspaceRuntimeQuickControls: () => <div data-testid="runtime-quick-controls" />,
WorkspaceRuntimeControls: () => <div data-testid="runtime-controls" />,
}));
vi.mock("../components/PageTabBar", () => ({
PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => (
<div data-testid="page-tab-bar">
{items.map((item) => (
<button key={item.value} data-tab-value={item.value} type="button">{item.label}</button>
))}
</div>
),
}));
vi.mock("../components/CopyText", () => ({ CopyText: () => null }));
function workspace(overrides: Partial<ExecutionWorkspace> = {}): ExecutionWorkspace {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "workspace-1",
companyId: "company-1",
projectId: "project-1",
projectWorkspaceId: null,
sourceIssueId: null,
mode: "local",
strategyType: "local_worktree",
name: "Diff worktree",
status: "active",
cwd: "/tmp/workspace-1",
repoUrl: null,
baseRef: null,
branchName: null,
providerType: "local",
providerRef: null,
derivedFromExecutionWorkspaceId: null,
lastUsedAt: now,
openedAt: now,
closedAt: null,
cleanupEligibleAt: null,
cleanupReason: null,
config: null,
metadata: null,
runtimeServices: [],
createdAt: now,
updatedAt: now,
...overrides,
} as ExecutionWorkspace;
}
function project(overrides: Partial<Project> = {}): Project {
const now = new Date("2026-05-01T00:00:00Z");
return {
id: "project-1",
companyId: "company-1",
urlKey: "project-1",
goalId: null,
goalIds: [],
goals: [],
name: "Test Project",
description: null,
status: "in_progress",
leadAgentId: null,
targetDate: null,
color: "#14b8a6",
env: null,
pauseReason: null,
pausedAt: null,
executionWorkspacePolicy: null,
codebase: {
workspaceId: null,
repoUrl: null,
repoRef: null,
defaultRef: null,
repoName: null,
localFolder: null,
managedFolder: "/tmp/project-1",
effectiveLocalFolder: "/tmp/project-1",
origin: "managed_checkout",
},
workspaces: [],
primaryWorkspace: null,
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("ExecutionWorkspaceDetail plugin slots", () => {
let root: Root | null = null;
let container: HTMLDivElement;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
mockExecutionWorkspacesApi.get.mockResolvedValue(workspace());
mockExecutionWorkspacesApi.listWorkspaceOperations.mockResolvedValue([]);
mockProjectsApi.get.mockResolvedValue(project());
mockIssuesApi.list.mockResolvedValue([]);
mockAgentsApi.list.mockResolvedValue([]);
mockRoutinesApi.list.mockResolvedValue([]);
mockHeartbeatsApi.liveRunsForCompany.mockResolvedValue([]);
});
afterEach(() => {
act(() => root?.unmount());
root = null;
container.remove();
vi.clearAllMocks();
});
async function render() {
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
await act(async () => {
root = createRoot(container);
root.render(
<QueryClientProvider client={queryClient}>
<ExecutionWorkspaceDetail />
</QueryClientProvider>,
);
});
await act(async () => {
await flush();
});
}
it("scopes the plugin detail-tab discovery to execution_workspace and the workspace's company", async () => {
await render();
const enabledDetailTabFilters = mockUsePluginSlots.mock.calls
.map(([filters]) => filters as { slotTypes: string[]; entityType: string; companyId: string | null; enabled?: boolean })
.filter((filters) => filters.slotTypes.includes("detailTab") && filters.enabled !== false);
expect(enabledDetailTabFilters.length).toBeGreaterThan(0);
for (const filters of enabledDetailTabFilters) {
expect(filters.entityType).toBe("execution_workspace");
expect(filters.companyId).toBe("company-1");
}
});
it("mounts a toolbar PluginSlotOutlet with execution_workspace context", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as {
slotTypes: string[];
entityType: string;
context: { entityId: string; entityType: string; companyId: string; projectId: string };
});
const toolbarOutlet = outletCalls.find((props) => props.slotTypes.includes("toolbarButton"));
expect(toolbarOutlet).toBeDefined();
expect(toolbarOutlet?.entityType).toBe("execution_workspace");
expect(toolbarOutlet?.context).toMatchObject({
entityId: "workspace-1",
entityType: "execution_workspace",
companyId: "company-1",
projectId: "project-1",
});
});
it("does not mount plugin slots scoped to other entity types", async () => {
await render();
const outletCalls = mockPluginSlotOutlet.mock.calls.map(([props]) => props as { entityType: string });
for (const props of outletCalls) {
expect(props.entityType).toBe("execution_workspace");
}
});
});
+59 -4
View File
@@ -19,6 +19,7 @@ import { projectsApi } from "../api/projects";
import { routinesApi } from "../api/routines";
import { IssuesList } from "../components/IssuesList";
import { PageTabBar } from "../components/PageTabBar";
import { PluginSlotMount, PluginSlotOutlet, usePluginSlots } from "@/plugins/slots";
import {
RoutineRunVariablesDialog,
type RoutineRunDialogSubmitData,
@@ -54,9 +55,15 @@ type WorkspaceFormState = {
workspaceRuntime: string;
};
type ExecutionWorkspaceTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspaceBaseTab = "services" | "configuration" | "runtime_logs" | "issues" | "routines";
type ExecutionWorkspacePluginTab = `plugin:${string}`;
type ExecutionWorkspaceTab = ExecutionWorkspaceBaseTab | ExecutionWorkspacePluginTab;
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceTab | null {
function isExecutionWorkspacePluginTab(value: string | null): value is ExecutionWorkspacePluginTab {
return typeof value === "string" && value.startsWith("plugin:");
}
function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): ExecutionWorkspaceBaseTab | null {
const segments = pathname.split("/").filter(Boolean);
const executionWorkspacesIndex = segments.indexOf("execution-workspaces");
if (executionWorkspacesIndex === -1 || segments[executionWorkspacesIndex + 1] !== workspaceId) return null;
@@ -69,7 +76,7 @@ function resolveExecutionWorkspaceTab(pathname: string, workspaceId: string): Ex
return null;
}
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceTab) {
function executionWorkspaceTabPath(workspaceId: string, tab: ExecutionWorkspaceBaseTab) {
const segment = tab === "runtime_logs" ? "runtime-logs" : tab;
return `/execution-workspaces/${workspaceId}/${segment}`;
}
@@ -536,7 +543,12 @@ export function ExecutionWorkspaceDetail() {
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState<string | null>(null);
const [runtimeActionMessage, setRuntimeActionMessage] = useState<string | null>(null);
const activeTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
const activeRouteTab = workspaceId ? resolveExecutionWorkspaceTab(location.pathname, workspaceId) : null;
const pluginTabFromSearch = useMemo(() => {
const tab = new URLSearchParams(location.search).get("tab");
return isExecutionWorkspacePluginTab(tab) ? tab : null;
}, [location.search]);
const activeTab: ExecutionWorkspaceTab | null = activeRouteTab ?? pluginTabFromSearch;
const workspaceQuery = useQuery({
queryKey: queryKeys.executionWorkspaces.detail(workspaceId!),
@@ -580,6 +592,21 @@ export function ExecutionWorkspaceDetail() {
() => project?.workspaces.find((item) => item.id === workspace?.projectWorkspaceId) ?? null,
[project, workspace?.projectWorkspaceId],
);
const { slots: workspacePluginDetailSlots } = usePluginSlots({
slotTypes: ["detailTab"],
entityType: "execution_workspace",
companyId: workspace?.companyId ?? null,
enabled: !!workspace?.companyId,
});
const workspacePluginTabItems = useMemo(
() => workspacePluginDetailSlots.map((slot) => ({
value: `plugin:${slot.pluginKey}:${slot.id}` as ExecutionWorkspacePluginTab,
label: slot.displayName,
slot,
})),
[workspacePluginDetailSlots],
);
const inheritedRuntimeConfig = linkedProjectWorkspace?.runtimeConfig?.workspaceRuntime ?? null;
const effectiveRuntimeConfig = workspace?.config?.workspaceRuntime ?? inheritedRuntimeConfig;
const runtimeConfigSource =
@@ -684,11 +711,23 @@ export function ExecutionWorkspaceDetail() {
});
const pendingRuntimeAction = controlRuntimeServices.isPending ? controlRuntimeServices.variables ?? null : null;
const pluginSlotContext = {
companyId: workspace.companyId,
projectId: workspace.projectId,
entityId: workspace.id,
entityType: "execution_workspace" as const,
};
const activePluginTab = workspacePluginTabItems.find((item) => item.value === activeTab) ?? null;
if (workspaceId && activeTab === null) {
return <LegacyWorkspaceTabRedirect workspaceId={workspaceId} />;
}
const handleTabChange = (tab: ExecutionWorkspaceTab) => {
if (isExecutionWorkspacePluginTab(tab)) {
navigate(`/execution-workspaces/${workspace.id}?tab=${encodeURIComponent(tab)}`);
return;
}
navigate(executionWorkspaceTabPath(workspace.id, tab));
};
@@ -731,6 +770,15 @@ export function ExecutionWorkspaceDetail() {
{runtimeActionErrorMessage ? <p className="text-sm text-destructive">{runtimeActionErrorMessage}</p> : null}
{!runtimeActionErrorMessage && runtimeActionMessage ? <p className="text-sm text-muted-foreground">{runtimeActionMessage}</p> : null}
<PluginSlotOutlet
slotTypes={["toolbarButton", "contextMenuItem"]}
entityType="execution_workspace"
context={pluginSlotContext}
className="flex flex-wrap gap-2"
itemClassName="inline-flex"
missingBehavior="placeholder"
/>
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
<PageTabBar
items={[
@@ -739,6 +787,7 @@ export function ExecutionWorkspaceDetail() {
{ value: "configuration", label: "Configuration" },
{ value: "runtime_logs", label: "Runtime logs" },
{ value: "routines", label: "Routines" },
...workspacePluginTabItems.map((item) => ({ value: item.value, label: item.label })),
]}
align="start"
value={activeTab ?? "issues"}
@@ -1128,6 +1177,12 @@ export function ExecutionWorkspaceDetail() {
error={linkedIssuesQuery.error as Error | null}
project={project}
/>
) : activePluginTab ? (
<PluginSlotMount
slot={activePluginTab.slot}
context={pluginSlotContext}
missingBehavior="placeholder"
/>
) : (
<ExecutionWorkspaceRoutinesList
workspace={workspace}