-
-
-
-
-
- {children}
+ {/* Keep one guard provider above both sidebar and content so browser history is tracked before guarded routes mount. */}
+
+
-
-
-
-
-
+
+
+
+
+
+
diff --git a/packages/web/src/app/(app)/settings/layout.tsx b/packages/web/src/app/(app)/settings/layout.tsx
index 422785ced..655ca28f5 100644
--- a/packages/web/src/app/(app)/settings/layout.tsx
+++ b/packages/web/src/app/(app)/settings/layout.tsx
@@ -87,7 +87,14 @@ export const getSidebarNavGroups = async () =>
{
title: "Ask Sourcebot",
href: `/settings/accountAskAgent`,
+ hrefRegex: `/settings/accountAskAgent(/.*)?$`,
icon: "bot" as const,
+ },
+ {
+ title: "Skills",
+ href: `/settings/skills`,
+ hrefRegex: `/settings/skills(/.*)?$`,
+ icon: "sparkles" as const,
}
] : []),
],
diff --git a/packages/web/src/app/(app)/settings/members/membersTable.tsx b/packages/web/src/app/(app)/settings/members/membersTable.tsx
index f657ba25b..a3d3078d9 100644
--- a/packages/web/src/app/(app)/settings/members/membersTable.tsx
+++ b/packages/web/src/app/(app)/settings/members/membersTable.tsx
@@ -16,7 +16,6 @@ import {
useReactTable,
} from "@tanstack/react-table";
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
-import * as TooltipPrimitive from "@radix-ui/react-tooltip";
declare module "@tanstack/react-table" {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
@@ -621,11 +620,9 @@ export const MembersTable = ({
-
-
- {section.description}
-
-
+
+ {section.description}
+
•
{section.rows.length}
diff --git a/packages/web/src/app/(app)/settings/skills/layout.tsx b/packages/web/src/app/(app)/settings/skills/layout.tsx
new file mode 100644
index 000000000..2015f25f2
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/skills/layout.tsx
@@ -0,0 +1,6 @@
+import React from "react";
+import { SettingsContainer } from "../components/settingsContainer";
+
+export default function SkillsSettingsLayout({ children }: { children: React.ReactNode }) {
+ return
{children};
+}
diff --git a/packages/web/src/app/(app)/settings/skills/page.tsx b/packages/web/src/app/(app)/settings/skills/page.tsx
new file mode 100644
index 000000000..230ca4a0e
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/skills/page.tsx
@@ -0,0 +1,49 @@
+import { listPersonalAgentSkills, listSharedAgentSkillCatalog } from "@/ee/features/chat/skills/actions";
+import { SkillsPage } from "@/ee/features/chat/skills/components/skillsPage";
+import { hasEntitlement } from "@/lib/entitlements";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
+import { authenticatedPage } from "@/middleware/authenticatedPage";
+import { OrgRole } from "@sourcebot/db";
+import { env } from "@sourcebot/shared";
+import { SkillsEntitlementMessage } from "./skillsEntitlementMessage";
+
+interface PageProps extends Record
{
+ // `skill` deep-links a specific skill as the initially-selected one (used by
+ // the workspace admin table's skill-name links).
+ searchParams: Promise<{ skill?: string }>;
+}
+
+export default authenticatedPage(async ({ user, role }, { searchParams }) => {
+ // Skills are part of Ask Sourcebot. Gate the EE skill UI behind the `ask`
+ // entitlement so it never renders on a non-entitled deployment; show the FSL
+ // upsell panel instead.
+ if (!(await hasEntitlement('ask'))) {
+ return ;
+ }
+
+ const [{ skill: initialSelectedId }, [personalSkills, sharedSkills]] = await Promise.all([
+ searchParams,
+ Promise.all([
+ listPersonalAgentSkills(),
+ listSharedAgentSkillCatalog(),
+ ]),
+ ]);
+ if (isServiceError(personalSkills)) {
+ throw new ServiceErrorException(personalSkills);
+ }
+ if (isServiceError(sharedSkills)) {
+ throw new ServiceErrorException(sharedSkills);
+ }
+
+ return (
+
+ );
+});
diff --git a/packages/web/src/app/(app)/settings/skills/skillsEntitlementMessage.tsx b/packages/web/src/app/(app)/settings/skills/skillsEntitlementMessage.tsx
new file mode 100644
index 000000000..0de5adfd2
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/skills/skillsEntitlementMessage.tsx
@@ -0,0 +1,23 @@
+"use client"
+
+import { UpsellPanel } from "@/features/billing/upsellDialog"
+
+/**
+ * Shown in place of the Skills management UI when the deployment is not on a plan
+ * that includes Ask Sourcebot. FSL (not ee/) so it can render for free-plan users
+ * as the upsell surface without mounting any ee/ skill code.
+ */
+export function SkillsEntitlementMessage() {
+ return (
+
+
+
+ )
+}
diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx
index fd2deff20..b632669fe 100644
--- a/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx
+++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/page.tsx
@@ -4,6 +4,9 @@ import { OrgRole } from "@sourcebot/db";
import { getMcpOAuthCallbackUrl } from "@/ee/features/chat/mcp/utils.server";
import { WorkspaceAskAgentPage } from "./workspaceAskAgentPage";
import { WorkspaceAskAgentEntitlementMessage } from "./workspaceAskAgentEntitlementMessage";
+import { listSharedAgentSkillManagement } from "@/ee/features/chat/skills/actions";
+import { ServiceErrorException } from "@/lib/serviceError";
+import { isServiceError } from "@/lib/utils";
interface PageProps extends Record {
searchParams: Promise<{
@@ -20,7 +23,8 @@ export default authenticatedPage(async ({ org, prisma }, { searchPara
// render it for teardown (the page itself disables "add" and only allows
// removal in that state). We only show the upsell when there is nothing to
// clean up.
- if (!(await hasEntitlement("ask"))) {
+ const hasAskEntitlement = await hasEntitlement("ask");
+ if (!hasAskEntitlement) {
const serverCount = await prisma.mcpServer.count({
where: { orgId: org.id },
});
@@ -31,6 +35,10 @@ export default authenticatedPage(async ({ org, prisma }, { searchPara
}
const { status, server, message } = await searchParams;
+ const orgSkills = hasAskEntitlement ? await listSharedAgentSkillManagement() : [];
+ if (isServiceError(orgSkills)) {
+ throw new ServiceErrorException(orgSkills);
+ }
return (
(async ({ org, prisma }, { searchPara
callbackServer={server}
callbackMessage={message}
oauthRedirectUrl={getMcpOAuthCallbackUrl()}
+ initialOrgSkills={orgSkills}
/>
);
}, { minRole: OrgRole.OWNER, redirectTo: '/settings' });
diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.test.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.test.tsx
new file mode 100644
index 000000000..5507b5f24
--- /dev/null
+++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.test.tsx
@@ -0,0 +1,270 @@
+import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { afterEach, describe, expect, test, vi } from 'vitest';
+import { TooltipProvider } from '@/components/ui/tooltip';
+import type { ReactNode } from 'react';
+import type { SharedAgentSkillManagementItem } from '@/ee/features/chat/skills/types';
+
+vi.mock('next/navigation', () => ({
+ useRouter: () => ({
+ push: vi.fn(),
+ }),
+}));
+
+// The shared-skills manager links skill names via next/link; render a plain anchor.
+vi.mock('next/link', async () => {
+ const { createElement } = await import('react');
+ return {
+ default: ({ href, children }: { href: string; children: ReactNode }) =>
+ createElement('a', { href }, children),
+ };
+});
+
+vi.mock('@/app/api/(client)/client', () => ({
+ getMcpConfiguration: vi.fn(),
+ getMcpServersWithStatus: vi.fn(),
+}));
+
+vi.mock('@/ee/features/chat/mcp/actions', () => ({
+ checkMcpServerDynamicClientRegistration: vi.fn(),
+ createMcpServer: vi.fn(),
+ createStaticOAuthMcpServer: vi.fn(),
+ deleteMcpServer: vi.fn(),
+ updateMcpServerOAuthScopes: vi.fn(),
+}));
+
+vi.mock('@/ee/features/chat/skills/actions', () => ({
+ deleteSharedAgentSkill: vi.fn(),
+ setSharedSkillFlag: vi.fn(),
+}));
+
+const clientApi = await import('@/app/api/(client)/client');
+const skillActions = await import('@/ee/features/chat/skills/actions');
+const { WorkspaceAskAgentPage } = await import('./workspaceAskAgentPage');
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+function renderWorkspaceAskAgentPage({
+ orgSkills,
+ isAskAgentAvailable = true,
+}: {
+ orgSkills: SharedAgentSkillManagementItem[];
+ isAskAgentAvailable?: boolean;
+}) {
+ vi.mocked(clientApi.getMcpConfiguration).mockResolvedValue({
+ allowedMode: 'approved_only',
+ isAskAgentAvailable,
+ servers: [],
+ });
+ vi.mocked(clientApi.getMcpServersWithStatus).mockResolvedValue([]);
+
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ retry: false,
+ },
+ },
+ });
+
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('WorkspaceAskAgentPage', () => {
+ test('hides shared skills when Ask is unavailable', async () => {
+ const sharedSkill: SharedAgentSkillManagementItem = {
+ id: 'skill-1',
+ scope: 'SHARED',
+ slug: 'review',
+ name: 'Review',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+
+ renderWorkspaceAskAgentPage({
+ orgSkills: [sharedSkill],
+ isAskAgentAvailable: false,
+ });
+
+ await screen.findByText('Ask Sourcebot connectors are unavailable');
+
+ expect(screen.queryByText('Review')).toBeNull();
+ expect(screen.queryByText('Available to everyone in your workspace.')).toBeNull();
+ });
+
+ test('disables shared skill delete while a flag update is pending', async () => {
+ const sharedSkill: SharedAgentSkillManagementItem = {
+ id: 'skill-1',
+ scope: 'SHARED',
+ slug: 'review',
+ name: 'Review',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+ vi.mocked(skillActions.setSharedSkillFlag).mockReturnValue(new Promise(() => undefined));
+
+ renderWorkspaceAskAgentPage({ orgSkills: [sharedSkill] });
+
+ await screen.findByText('Review');
+
+ const deleteButton = screen.getByRole('button', { name: 'Delete Review' });
+ expect(deleteButton.hasAttribute('disabled')).toBe(false);
+
+ fireEvent.click(screen.getByRole('switch', { name: 'Auto' }));
+
+ await waitFor(() => {
+ expect(deleteButton.hasAttribute('disabled')).toBe(true);
+ });
+ });
+
+ test("allows deleting a different shared skill while another skill's flag update is pending", async () => {
+ const skillA: SharedAgentSkillManagementItem = {
+ id: 'skill-a',
+ scope: 'SHARED',
+ slug: 'review-a',
+ name: 'Review A',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+ const skillB: SharedAgentSkillManagementItem = {
+ id: 'skill-b',
+ scope: 'SHARED',
+ slug: 'review-b',
+ name: 'Review B',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+ let resolveUpdate!: (value: SharedAgentSkillManagementItem) => void;
+ const pendingUpdate = new Promise((resolve) => {
+ resolveUpdate = resolve;
+ });
+ vi.mocked(skillActions.setSharedSkillFlag).mockReturnValue(pendingUpdate);
+
+ renderWorkspaceAskAgentPage({ orgSkills: [skillA, skillB] });
+
+ await screen.findByText('Review A');
+
+ fireEvent.click(screen.getAllByRole('switch', { name: 'Auto' })[0]);
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Delete Review A' }).hasAttribute('disabled')).toBe(true);
+ });
+
+ const skillBDeleteButton = screen.getByRole('button', { name: 'Delete Review B' });
+ expect(skillBDeleteButton.hasAttribute('disabled')).toBe(false);
+
+ fireEvent.click(skillBDeleteButton);
+
+ const dialog = await screen.findByRole('alertdialog', { name: 'Delete Shared Skill' });
+ expect(within(dialog).getByText('Review B')).not.toBeNull();
+
+ await act(async () => {
+ resolveUpdate({ ...skillA, autoEnrolled: true });
+ await pendingUpdate;
+ });
+ });
+
+ test('keeps each shared skill delete disabled until its own flag update finishes', async () => {
+ const skillA: SharedAgentSkillManagementItem = {
+ id: 'skill-a',
+ scope: 'SHARED',
+ slug: 'review-a',
+ name: 'Review A',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+ const skillB: SharedAgentSkillManagementItem = {
+ id: 'skill-b',
+ scope: 'SHARED',
+ slug: 'review-b',
+ name: 'Review B',
+ description: 'Review risky changes.',
+ enabled: true,
+ autoEnrolled: false,
+ source: null,
+ createdByEmail: 'creator@sourcebot.dev',
+ createdAt: '2026-06-18T00:00:00.000Z',
+ updatedAt: '2026-06-18T00:00:00.000Z',
+ };
+ let resolveA!: (value: SharedAgentSkillManagementItem) => void;
+ let resolveB!: (value: SharedAgentSkillManagementItem) => void;
+ const updateA = new Promise((resolve) => {
+ resolveA = resolve;
+ });
+ const updateB = new Promise((resolve) => {
+ resolveB = resolve;
+ });
+ vi.mocked(skillActions.setSharedSkillFlag).mockImplementation(({ skillId }) =>
+ skillId === 'skill-a' ? updateA : updateB,
+ );
+
+ renderWorkspaceAskAgentPage({ orgSkills: [skillA, skillB] });
+
+ await screen.findByText('Review A');
+ const autoSwitches = screen.getAllByRole('switch', { name: 'Auto' });
+
+ fireEvent.click(autoSwitches[0]);
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Delete Review A' }).hasAttribute('disabled')).toBe(true);
+ });
+
+ fireEvent.click(autoSwitches[1]);
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Delete Review B' }).hasAttribute('disabled')).toBe(true);
+ });
+
+ await act(async () => {
+ resolveA({ ...skillA, autoEnrolled: true });
+ await updateA;
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Delete Review A' }).hasAttribute('disabled')).toBe(false);
+ });
+ expect(screen.getByRole('button', { name: 'Delete Review B' }).hasAttribute('disabled')).toBe(true);
+
+ await act(async () => {
+ resolveB({ ...skillB, autoEnrolled: true });
+ await updateB;
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole('button', { name: 'Delete Review B' }).hasAttribute('disabled')).toBe(false);
+ });
+ });
+});
diff --git a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
index 4105134f8..78dc1131e 100644
--- a/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
+++ b/packages/web/src/app/(app)/settings/workspaceAskAgent/workspaceAskAgentPage.tsx
@@ -36,6 +36,8 @@ import { AlertTriangleIcon, CableIcon, CopyIcon, InfoIcon, KeyRoundIcon, Loader2
import { PrefabConnectorPopover } from "@/ee/features/chat/mcp/components/prefabConnectorPopover";
import Markdown from "react-markdown";
import { getStaticOAuthDescription, type PrefabMcpServer } from "@/ee/features/chat/mcp/prefabMcpServers";
+import { WorkspaceSharedSkillsManager } from "@/ee/features/chat/skills/components/workspaceSharedSkillsManager";
+import type { SharedAgentSkillManagementItem } from "@/ee/features/chat/skills/types";
import type { McpConfigurationServer, ServerToolsEntry } from "@/ee/features/chat/mcp/types";
function clearCallbackParams() {
@@ -55,6 +57,7 @@ interface WorkspaceAskAgentPageProps {
callbackServer?: string;
callbackMessage?: string;
oauthRedirectUrl: string;
+ initialOrgSkills: SharedAgentSkillManagementItem[];
}
type WorkspaceConnectorStatus = {
@@ -344,7 +347,7 @@ function WorkspaceConnectorCard({
);
}
-export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage, oauthRedirectUrl }: WorkspaceAskAgentPageProps) {
+export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callbackMessage, oauthRedirectUrl, initialOrgSkills }: WorkspaceAskAgentPageProps) {
const { toast } = useToast();
const queryClient = useQueryClient();
const router = useRouter();
@@ -419,6 +422,7 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
const servers = data?.servers ?? [];
const canCreateConnectors = data?.isAskAgentAvailable === true;
const isAskAgentUnavailable = data?.isAskAgentAvailable === false;
+ const canManageWorkspaceSkills = data?.isAskAgentAvailable === true;
const connectedServerCount = useMemo(
() => serversWithStatus?.filter((server) => server.isConnected).length ?? 0,
[serversWithStatus],
@@ -760,6 +764,13 @@ export function WorkspaceAskAgentPage({ callbackStatus, callbackServer, callback
)}
+ {canManageWorkspaceSkills && (
+ <>
+