mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-07 07:55:47 +01:00
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:
1 parent
4c47eb46c3
commit
133366e216
4 files changed
+335
-4
No files matched your search
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user