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 ? (
+
) : (