diff --git a/canvas/src/components/tabs/SkillsTab.tsx b/canvas/src/components/tabs/SkillsTab.tsx
index bcd69400..c64d2667 100644
--- a/canvas/src/components/tabs/SkillsTab.tsx
+++ b/canvas/src/components/tabs/SkillsTab.tsx
@@ -297,10 +297,49 @@ export function SkillsTab({ workspaceId, data }: Props) {
}
};
+ // Compact-empty pattern: when the workspace has zero plugins
+ // installed AND the registry isn't open, collapse the whole
+ // "Plugins" section into a single inline pill rather than rendering
+ // the full panel chrome. Reported on production 2026-05-05 (#2971):
+ // the empty state's panel-with-zero-list-rows layout gives the user
+ // a lot of vertical real estate for content that's just "0
+ // installed + Install button". The compact form keeps that
+ // affordance without the chrome.
+ //
+ // Expanded/full layout still fires when installed.length > 0 OR
+ // when the user opens the registry (clicked "+ Install Plugin").
+ // Once a plugin is installed the section auto-expands to surface
+ // the list.
+ const compactEmpty = installed.length === 0 && !showRegistry && installedLoaded;
+
+ if (compactEmpty) {
+ return (
+
+
+
+ Plugins
+ 0 installed
+
+
+
+
+ );
+ }
+
return (
{/* Plugins section */}
-
+
Plugins
@@ -311,6 +350,8 @@ export function SkillsTab({ workspaceId, data }: Props) {
diff --git a/canvas/src/components/tabs/__tests__/SkillsTab.compactEmpty.test.tsx b/canvas/src/components/tabs/__tests__/SkillsTab.compactEmpty.test.tsx
new file mode 100644
index 00000000..013438fe
--- /dev/null
+++ b/canvas/src/components/tabs/__tests__/SkillsTab.compactEmpty.test.tsx
@@ -0,0 +1,141 @@
+// @vitest-environment jsdom
+//
+// Pins the compact-when-empty layout for the SkillsTab Plugins section
+// (issue #2971, reported on production 2026-05-05).
+//
+// Three states matter for layout:
+// 1. installed.length === 0 + registry closed + load completed → COMPACT pill
+// 2. installed.length > 0 → FULL panel + installed list
+// 3. registry open (showRegistry=true) → FULL panel + registry browser
+//
+// The compact-empty path is the new behavior; the other two were
+// pre-existing. This test pins all three so a future refactor that
+// over-collapses (showing compact when plugins are installed) or
+// over-expands (showing full panel on empty load) fails loudly.
+
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, screen, cleanup, fireEvent, waitFor } from "@testing-library/react";
+import React from "react";
+
+afterEach(cleanup);
+
+const apiGet = vi.fn();
+vi.mock("@/lib/api", () => ({
+ api: {
+ get: (path: string, opts?: unknown) => apiGet(path, opts),
+ post: vi.fn(() => Promise.resolve({})),
+ del: vi.fn(),
+ patch: vi.fn(),
+ put: vi.fn(),
+ },
+}));
+
+beforeEach(() => {
+ apiGet.mockReset();
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+import { SkillsTab } from "../SkillsTab";
+
+const minimalData = {
+ status: "online" as const,
+ runtime: "claude-code",
+ currentTask: "",
+ agentCard: undefined,
+} as unknown as Parameters[0]["data"];
+
+describe("SkillsTab Plugins compact-empty layout", () => {
+ it("renders compact pill when installed.length === 0 and registry closed", async () => {
+ // Both fetches return empty arrays — workspace is fresh, no plugins.
+ apiGet.mockImplementation((path: string) => {
+ if (path.endsWith("/plugins") || path === "/plugins" || path === "/plugins/sources") {
+ return Promise.resolve([]);
+ }
+ return Promise.resolve([]);
+ });
+ render();
+
+ // Wait for the installedLoaded gate to flip — without that the
+ // component renders a "loading" state, not the compact pill.
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
+ });
+
+ // Compact assertions: the rounded-xl panel chrome MUST NOT be in
+ // the DOM (we'd see two "Plugins" labels — one in the header,
+ // one in the pill — if the layout regressed to "always full
+ // panel"). The compact form has exactly one "Plugins" label.
+ const labels = screen.getAllByText("Plugins");
+ expect(labels).toHaveLength(1);
+
+ // The full-panel chrome's id="plugins-section" should NOT be
+ // rendered when we're in compact mode.
+ expect(document.getElementById("plugins-section")).toBeNull();
+ });
+
+ it("renders full panel when installed.length > 0", async () => {
+ apiGet.mockImplementation((path: string) => {
+ if (path.endsWith("/plugins")) {
+ return Promise.resolve([
+ { name: "memory-postgres", version: "1.0.0", description: "memory backend", supported_on_runtime: true },
+ ]);
+ }
+ return Promise.resolve([]);
+ });
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByText(/1 installed/i)).toBeTruthy();
+ });
+
+ // Full-panel chrome MUST be present — id pin.
+ expect(document.getElementById("plugins-section")).not.toBeNull();
+ // Compact pill ariaLabel MUST NOT be present.
+ expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
+ });
+
+ it("expands to full panel when user clicks + Install Plugin from compact pill", async () => {
+ apiGet.mockImplementation(() => Promise.resolve([]));
+ render();
+
+ // Start compact — wait for the compact pill to settle so we click
+ // the right button (initial render before installedLoaded flips
+ // doesn't have either layout, and the post-load compact pill is
+ // what we want to interact with).
+ await waitFor(() => {
+ expect(screen.getByLabelText(/Plugins \(none installed\)/i)).toBeTruthy();
+ });
+ const installBtn = screen.getByRole("button", { name: /\+ Install Plugin/i });
+ expect(installBtn.getAttribute("aria-expanded")).toBe("false");
+
+ fireEvent.click(installBtn);
+
+ // After click, registry opens → full panel renders. The compact
+ // pill's aria-label should be gone; the full-panel id should
+ // appear. Generous waitFor — a registry fetch may also fire in
+ // the React effect chain, and we want to assert the compact →
+ // full transition without racing it.
+ await waitFor(
+ () => {
+ expect(document.getElementById("plugins-section")).not.toBeNull();
+ },
+ { timeout: 3000 },
+ );
+ expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
+ });
+
+ it("does NOT collapse to compact while initial load is pending (avoid flash)", () => {
+ // Returning a never-resolving promise means installedLoaded stays
+ // false. The compact pill MUST NOT render in this state — that
+ // would flash compact → full as the load completes, which looks
+ // janky. The component shows a loading shell instead (the
+ // existing pre-fix behavior).
+ apiGet.mockImplementation(() => new Promise(() => {}));
+ render();
+
+ // Synchronous assertion — no waitFor — since we want to confirm
+ // the compact pill is NOT rendered before any network round-trip
+ // finishes.
+ expect(screen.queryByLabelText(/Plugins \(none installed\)/i)).toBeNull();
+ });
+});