mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-04 19:28:46 +01:00
Handle missing workspace plugin tabs
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c91fd1edef
commit
1d26d33aa4
5 files changed
+224
-56
No files matched your search
@@ -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 (
|
||||
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
||||
<div className="flex flex-col items-start gap-3">
|
||||
<p>Workspace plugin tab is not available.</p>
|
||||
<Button variant="outline" size="sm" asChild>
|
||||
<Link to={defaultTabHref}>{defaultTabLabel}</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }) => <a href={to}>{children}</a>,
|
||||
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
Navigate: ({ to }: { to: string }) => <div data-testid="navigate">{to}</div>,
|
||||
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> = {}): Project {
|
||||
};
|
||||
}
|
||||
|
||||
function pluginSlot(overrides: Record<string, unknown> = {}) {
|
||||
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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
<Tabs value={activeTab ?? "issues"} onValueChange={(value) => handleTabChange(value as ExecutionWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "issues", label: "Issues" },
|
||||
{ value: "services", label: "Services" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "runtime_logs", label: "Runtime logs" },
|
||||
{ value: "routines", label: "Routines" },
|
||||
...workspacePluginTabItems.map((item) => ({ 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() {
|
||||
<Card>
|
||||
<CardContent className="py-6 text-sm text-destructive">{workspacePluginDetailSlotsError}</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
) : isExecutionWorkspacePluginTab(activeTab) ? (
|
||||
<MissingPluginTabPlaceholder
|
||||
defaultTabHref={executionWorkspaceTabPath(workspace.id, "issues")}
|
||||
defaultTabLabel="Back to issues"
|
||||
/>
|
||||
) : activeTab === "routines" ? (
|
||||
<ExecutionWorkspaceRoutinesList
|
||||
workspace={workspace}
|
||||
project={project}
|
||||
/>
|
||||
) : (
|
||||
<LegacyWorkspaceTabRedirect workspaceId={workspace.id} />
|
||||
)}
|
||||
</div>
|
||||
<ExecutionWorkspaceCloseDialog
|
||||
|
||||
@@ -29,7 +29,9 @@ const mockPluginSlotState = vi.hoisted(() => ({
|
||||
vi.mock("../api/projects", () => ({ projectsApi: mockProjectsApi }));
|
||||
|
||||
vi.mock("@/lib/router", () => ({
|
||||
Link: ({ children, to }: { children?: ReactNode; to: string }) => <a href={to}>{children}</a>,
|
||||
Link: ({ children, to, className }: { children?: ReactNode; to: string; className?: string }) => (
|
||||
<a href={to} className={className}>{children}</a>
|
||||
),
|
||||
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";
|
||||
|
||||
@@ -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
|
||||
</Link>
|
||||
</Button>
|
||||
<div className="inline-flex items-center rounded-full border border-border bg-background px-2.5 py-1 text-xs text-muted-foreground">
|
||||
{workspace.isPrimary ? "Primary workspace" : "Secondary workspace"}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Project workspace
|
||||
</div>
|
||||
<h1 className="truncate text-xl font-semibold sm:text-2xl">{workspace.name}</h1>
|
||||
</div>
|
||||
{!workspace.isPrimary ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={setPrimaryWorkspace.isPending}
|
||||
onClick={() => setPrimaryWorkspace.mutate()}
|
||||
>
|
||||
{setPrimaryWorkspace.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <Check className="mr-2 h-4 w-4" />}
|
||||
Make primary
|
||||
</Button>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
This is the project’s primary codebase workspace.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(value) => handleTabChange(value as ProjectWorkspaceTab)}>
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
...pluginTabItems.map((item) => ({ 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() {
|
||||
<div className="grid gap-6 lg:grid-cols-[minmax(0,1.4fr)_minmax(18rem,0.9fr)]">
|
||||
<div className="space-y-6">
|
||||
<div className="rounded-2xl border border-border bg-card p-5">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:flex-wrap sm:items-start sm:justify-between">
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium uppercase tracking-[0.16em] text-muted-foreground">
|
||||
Project workspace
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold">{workspace.name}</h1>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
{!workspace.isPrimary ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
className="w-full sm:w-auto"
|
||||
disabled={setPrimaryWorkspace.isPending}
|
||||
onClick={() => setPrimaryWorkspace.mutate()}
|
||||
>
|
||||
{setPrimaryWorkspace.isPending
|
||||
? <Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
: <Check className="mr-2 h-4 w-4" />}
|
||||
Make primary
|
||||
</Button>
|
||||
) : (
|
||||
<div className="inline-flex items-center gap-2 rounded-xl border border-emerald-500/25 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300 sm:max-w-sm">
|
||||
<Sparkles className="h-4 w-4" />
|
||||
This is the project’s primary codebase workspace.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="max-w-2xl text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<Separator className="my-5" />
|
||||
|
||||
@@ -716,14 +734,15 @@ export function ProjectWorkspaceDetail() {
|
||||
}}
|
||||
missingBehavior="placeholder"
|
||||
/>
|
||||
) : (
|
||||
) : pluginDetailSlotsLoading || pluginDetailSlotsError ? (
|
||||
<div className="rounded-lg border border-dashed border-border bg-background px-4 py-8 text-sm text-muted-foreground">
|
||||
{pluginDetailSlotsError
|
||||
? pluginDetailSlotsError
|
||||
: pluginDetailSlotsLoading
|
||||
? "Loading workspace plugin..."
|
||||
: "Workspace plugin tab is not available."}
|
||||
{pluginDetailSlotsError ? pluginDetailSlotsError : "Loading workspace plugin..."}
|
||||
</div>
|
||||
) : (
|
||||
<MissingPluginTabPlaceholder
|
||||
defaultTabHref={`${projectWorkspaceUrl(project, routeWorkspaceId)}?tab=configuration`}
|
||||
defaultTabLabel="Back to configuration"
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user