diff --git a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx
index 943b5fa575..2639530eb5 100644
--- a/apps/web/components/app-sidebar/sections/integrations-section.test.tsx
+++ b/apps/web/components/app-sidebar/sections/integrations-section.test.tsx
@@ -40,6 +40,27 @@ vi.mock("@/components/integrations/integrations-menu", () => ({
useConfiguredIntegrationLinks: linksMock,
}));
+const pluginsMock = vi.hoisted(() => ({
+ enabled: true,
+ navItems: [] as Array<{
+ id: string;
+ label: string;
+ path: string;
+ icon?: string;
+ section?: string;
+ }>,
+}));
+
+vi.mock("@/hooks/domains/features/use-feature", () => ({
+ useFeature: (flag: string) => flag === "plugins" && pluginsMock.enabled,
+}));
+
+vi.mock("@/lib/plugins/registry", () => ({
+ usePluginRegistry: () => ({
+ getNavItems: () => pluginsMock.navItems,
+ }),
+}));
+
vi.mock("@kandev/ui/collapsible", () => ({
Collapsible: ({ children, open }: { children: ReactNode; open?: boolean }) => {
collapsibleMock.open = !!open;
@@ -69,6 +90,8 @@ describe("IntegrationsSection", () => {
{ id: "github", label: "GitHub", href: "/github" },
{ id: "jira", label: "Jira", href: "/jira" },
]);
+ pluginsMock.enabled = true;
+ pluginsMock.navItems = [];
});
afterEach(() => cleanup());
@@ -115,4 +138,52 @@ describe("IntegrationsSection", () => {
expect(screen.getAllByTestId("integration-header-shortcut")).toHaveLength(4);
expect(screen.getByRole("link", { name: "Sentry" })).toBeTruthy();
});
+
+ const costPerModelItem = {
+ id: "cost-per-model",
+ label: "Cost per Model",
+ path: "/cost-per-model",
+ icon: "chart",
+ section: "integrations",
+ };
+ const costPerModelTestId = `plugin-nav-item-${costPerModelItem.id}`;
+
+ it("renders plugin nav items registered with section integrations after the first-party links", () => {
+ storeState.appSidebar.sectionExpanded.integrations = true;
+ pluginsMock.navItems = [
+ costPerModelItem,
+ { id: "hello", label: "Hello", path: "/hello", section: "main" },
+ ];
+
+ renderSection();
+
+ const pluginRow = screen.getByTestId(costPerModelTestId);
+ expect(pluginRow.getAttribute("href")).toBe(costPerModelItem.path);
+ expect(screen.queryByRole("link", { name: "Hello" })).toBeNull();
+ });
+
+ it("shows the section when only plugin integration items exist, with no empty header-action slot", () => {
+ storeState.appSidebar.sectionExpanded.integrations = true;
+ linksMock.mockReturnValue([]);
+ pluginsMock.navItems = [costPerModelItem];
+
+ const { container } = renderSection();
+
+ expect(screen.getByTestId(costPerModelTestId)).toBeTruthy();
+ // Regression for the empty headerAction slot: AppSidebarSection renders
+ // a "shrink-0 mr-1 flex items-center" wrapper whenever headerAction is
+ // non-null, even with zero shortcuts inside it.
+ expect(container.querySelector(".shrink-0.mr-1")).toBeNull();
+ });
+
+ it("hides plugin items (and an otherwise empty section) when the plugins feature is off", () => {
+ storeState.appSidebar.sectionExpanded.integrations = true;
+ linksMock.mockReturnValue([]);
+ pluginsMock.enabled = false;
+ pluginsMock.navItems = [costPerModelItem];
+
+ const { container } = renderSection();
+
+ expect(container.textContent).toBe("");
+ });
});
diff --git a/apps/web/components/app-sidebar/sections/integrations-section.tsx b/apps/web/components/app-sidebar/sections/integrations-section.tsx
index 45d129bc74..44ca131a9b 100644
--- a/apps/web/components/app-sidebar/sections/integrations-section.tsx
+++ b/apps/web/components/app-sidebar/sections/integrations-section.tsx
@@ -12,6 +12,9 @@ import {
import type { Icon as TablerIcon } from "@tabler/icons-react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { useConfiguredIntegrationLinks } from "@/components/integrations/integrations-menu";
+import { useFeature } from "@/hooks/domains/features/use-feature";
+import { resolvePluginIcon } from "@/lib/plugins/icons";
+import { usePluginRegistry } from "@/lib/plugins/registry";
import { cn } from "@/lib/utils";
import {
APP_SIDEBAR_SECTION_IDS,
@@ -60,11 +63,44 @@ function IntegrationHeaderShortcuts({ links }: { links: ConfiguredIntegrationLin
);
}
+type IntegrationRowProps = {
+ href: string;
+ label: string;
+ icon: TablerIcon;
+ active: boolean;
+ testId?: string;
+};
+
+function IntegrationRow({ href, label, icon: Icon, active, testId }: IntegrationRowProps) {
+ return (
+
+
+ {label}
+
+ );
+}
+
export function IntegrationsSection({ collapsed }: IntegrationsSectionProps) {
const pathname = usePathname();
const links = useConfiguredIntegrationLinks();
+ // Plugin-registered nav items that target this section
+ // (`registerNavItem({ section: "integrations" })`), rendered after the
+ // first-party links. Gated on the "plugins" feature flag like every other
+ // plugin surface.
+ const pluginsEnabled = useFeature("plugins");
+ const registry = usePluginRegistry();
+ const pluginItems = pluginsEnabled
+ ? registry.getNavItems().filter((item) => item.section === "integrations")
+ : [];
- if (links.length === 0) return null;
+ if (links.length === 0 && pluginItems.length === 0) return null;
return (
}
+ headerAction={links.length > 0 ? : undefined}
headerActionVisibility="always"
>
- {links.map(({ id, label, href }) => {
- const Icon = INTEGRATION_ICONS[id] ?? IconPlugConnected;
- const isActive = pathname === href || pathname.startsWith(`${href}/`);
- return (
-
-
- {label}
-
- );
- })}
+ {links.map(({ id, label, href }) => (
+
+ ))}
+ {pluginItems.map((item) => (
+
+ ))}
);
}
diff --git a/apps/web/lib/plugins/types.ts b/apps/web/lib/plugins/types.ts
index 8ada82b218..5cb2a5c2ef 100644
--- a/apps/web/lib/plugins/types.ts
+++ b/apps/web/lib/plugins/types.ts
@@ -21,7 +21,12 @@ export interface NavItem {
path: string;
/** Curated icon name (see `lib/plugins/icons.ts`); unknown names render the puzzle glyph. */
icon?: string;
- section?: "main" | "settings";
+ /**
+ * Where the item renders: "main" (default) as a top-level sidebar entry,
+ * "integrations" inside the sidebar's Integrations section alongside the
+ * first-party integration links.
+ */
+ section?: "main" | "settings" | "integrations";
}
/**
diff --git a/docs/plans/plugins/PLUGIN-API.md b/docs/plans/plugins/PLUGIN-API.md
index 6790f85cfc..df372ceba8 100644
--- a/docs/plans/plugins/PLUGIN-API.md
+++ b/docs/plans/plugins/PLUGIN-API.md
@@ -82,7 +82,11 @@ fine.
// icon: curated icon name (apps/web/lib/plugins/icons.ts — "ticket", "chart",
// "robot", "database", ...); unknown/missing names render a puzzle glyph in
// the sidebar.
-interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings"; }
+// section: "main" (default) renders as a top-level sidebar entry;
+// "integrations" renders inside the sidebar's Integrations section alongside
+// the first-party integration links (GitHub, Jira, ...). Hosts predating a
+// section value simply don't render items targeting it (additive change).
+interface NavItem { id: string; label: string; path: string; icon?: string; section?: "main" | "settings" | "integrations"; }
// Configuration for the kandev-style title bar the host renders above a plugin
// route. All fields optional; defaults are derived (see registerRoute below).