diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index c2f8f807889..b7c7917ed94 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -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]; diff --git a/packages/shared/src/validators/plugin.test.ts b/packages/shared/src/validators/plugin.test.ts index ccea0d6a3f6..5369902c12b 100644 --- a/packages/shared/src/validators/plugin.test.ts +++ b/packages/shared/src/validators/plugin.test.ts @@ -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"]); + }); }); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.test.tsx b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx new file mode 100644 index 00000000000..7bb4ca748a5 --- /dev/null +++ b/ui/src/pages/ExecutionWorkspaceDetail.test.tsx @@ -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 }) => {children}, + Navigate: ({ to }: { to: string }) =>
{to}
, + 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
; + }, + PluginSlotOutlet: (props: unknown) => { + mockPluginSlotOutlet(props); + return
; + }, + usePluginSlots: (filters: unknown) => { + mockUsePluginSlots(filters); + return { slots: [], isLoading: false, errorMessage: null }; + }, +})); + +vi.mock("../components/IssuesList", () => ({ + IssuesList: () =>
, +})); +vi.mock("../components/ExecutionWorkspaceCloseDialog", () => ({ + ExecutionWorkspaceCloseDialog: () => null, +})); +vi.mock("../components/RoutineRunVariablesDialog", () => ({ + RoutineRunVariablesDialog: () => null, +})); +vi.mock("../components/WorkspaceRuntimeControls", () => ({ + buildWorkspaceRuntimeControlSections: () => [], + WorkspaceRuntimeQuickControls: () =>
, + WorkspaceRuntimeControls: () =>
, +})); +vi.mock("../components/PageTabBar", () => ({ + PageTabBar: ({ items }: { items: Array<{ value: string; label: string }> }) => ( +
+ {items.map((item) => ( + + ))} +
+ ), +})); +vi.mock("../components/CopyText", () => ({ CopyText: () => null })); + +function workspace(overrides: Partial = {}): 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 { + 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( + + + , + ); + }); + 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"); + } + }); +}); diff --git a/ui/src/pages/ExecutionWorkspaceDetail.tsx b/ui/src/pages/ExecutionWorkspaceDetail.tsx index f541616c07b..c744fcf7e7c 100644 --- a/ui/src/pages/ExecutionWorkspaceDetail.tsx +++ b/ui/src/pages/ExecutionWorkspaceDetail.tsx @@ -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(null); const [runtimeActionErrorMessage, setRuntimeActionErrorMessage] = useState(null); const [runtimeActionMessage, setRuntimeActionMessage] = useState(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 ; } 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 ?

{runtimeActionErrorMessage}

: null} {!runtimeActionErrorMessage && runtimeActionMessage ?

{runtimeActionMessage}

: null} + + handleTabChange(value as ExecutionWorkspaceTab)}> ({ 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 ? ( + ) : (