diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 2d05bb78a..fbfff1054 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -18,6 +18,7 @@ import {
APP_ROOT_ROUTE_PATH,
AUTH_CALLBACK_ROUTE_PATH,
AUTOMATIONS_ROUTE_PATH,
+ SKILLS_ROUTE_PATH,
AUTOMATION_DETAIL_ROUTE_PATH,
LEGACY_PROJECT_COMPOSE_ROUTE_PATH,
POPOUT_ROUTE_PATH,
@@ -52,6 +53,11 @@ const AutomationDetailView = lazy(() =>
default: m.AutomationDetailView,
})),
);
+const SkillsView = lazy(() =>
+ import("./views/SkillsView").then((m) => ({
+ default: m.SkillsView,
+ })),
+);
const ProjectSettingsView = lazy(() =>
import("./views/ProjectSettingsView").then((m) => ({
default: m.ProjectSettingsView,
@@ -109,6 +115,7 @@ function AppRoutes() {
path={AUTOMATION_DETAIL_ROUTE_PATH}
element={}
/>
+ } />
}
diff --git a/apps/app/src/components/create-via-prompt-examples.tsx b/apps/app/src/components/create-via-prompt-examples.tsx
new file mode 100644
index 000000000..f16ee25ad
--- /dev/null
+++ b/apps/app/src/components/create-via-prompt-examples.tsx
@@ -0,0 +1,214 @@
+import { Button } from "@/components/ui/button.js";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuLabel,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu.js";
+import { Icon } from "@/components/ui/icon.js";
+import {
+ CREATE_LOOP_PROMPT,
+ CREATE_SKILL_PROMPT,
+} from "@/components/promptbox/PromptBoxActionsMenu";
+
+export type CreateViaPromptKind = "skill" | "loop";
+
+interface Example {
+ label: string;
+ /** Completes the "Create a new bb {kind} …" prompt; also shown on the card. */
+ description: string;
+}
+
+interface KindConfig {
+ prefix: string;
+ explainer: string;
+ examples: readonly Example[];
+}
+
+// The description completes the prompt prefix, so each card both teaches and
+// seeds the composer. Skills are standard Agent Skills whose bb edge is being
+// cross-provider; loops are cheap scripts that escalate to threads.
+const CONFIG: Record = {
+ skill: {
+ prefix: CREATE_SKILL_PROMPT,
+ explainer:
+ "Write a skill once, and every agent in bb can run it, whatever the provider.",
+ examples: [
+ {
+ label: "Repro & fix",
+ description:
+ "turns a bug report into a failing test, then makes it pass",
+ },
+ {
+ label: "Scaffold to our patterns",
+ description:
+ "scaffolds a new component with its test and story to match our conventions",
+ },
+ {
+ label: "Onboard to a subsystem",
+ description:
+ "traces how a feature works across the codebase and writes an explainer",
+ },
+ ],
+ },
+ loop: {
+ prefix: CREATE_LOOP_PROMPT,
+ explainer:
+ "Pay for agents only when there's real work, and fan a problem out across many threads in parallel.",
+ examples: [
+ {
+ label: "Flaky-test sweep",
+ description:
+ "run nightly, find flaky tests with a script, and spawn a fixer thread for each one",
+ },
+ {
+ label: "Silent health watch",
+ description:
+ "check the app every 15 minutes with a cheap script and spawn a thread only when something breaks",
+ },
+ {
+ label: "Error sentinel",
+ description:
+ "poll the error dashboard hourly and spawn a triage thread only on a new spike",
+ },
+ ],
+ },
+};
+
+export interface CreateExample {
+ label: string;
+ description: string;
+ /** Full composer prompt seeded when this example is picked. */
+ prompt: string;
+}
+
+/**
+ * The shared create-via-prompt content for a kind: the marketing one-liner and
+ * the examples with their full seeded prompts. Surfaces render it how they like
+ * (cards, chips) without duplicating the copy.
+ */
+export function getCreateExamples(kind: CreateViaPromptKind): {
+ explainer: string;
+ examples: CreateExample[];
+} {
+ const config = CONFIG[kind];
+ return {
+ explainer: config.explainer,
+ examples: config.examples.map((example) => ({
+ label: example.label,
+ description: example.description,
+ prompt: `${config.prefix}${example.description}.`,
+ })),
+ };
+}
+
+export interface CreateViaPromptExamplesProps {
+ kind: CreateViaPromptKind;
+ /** Opens the composer seeded with the given full prompt. */
+ onCreate: (prompt: string) => void;
+}
+
+/**
+ * Teaching panel for the Loops empty state: a one-line explainer plus clickable
+ * example cards that seed the create-via-prompt composer.
+ */
+export function CreateViaPromptExamples({
+ kind,
+ onCreate,
+}: CreateViaPromptExamplesProps) {
+ const { explainer, examples } = getCreateExamples(kind);
+ return (
+
+
{explainer}
+
+ Start from an example
+
+
+ {examples.map((example) => (
+
+ ))}
+
+
+ );
+}
+
+export interface CreateWithTemplatesButtonProps {
+ kind: CreateViaPromptKind;
+ /** Main-button text, e.g. "New loop" or "New bb skill". */
+ label: string;
+ /** Blank when called with no argument; seeded when given an example prompt. */
+ onCreate: (prompt?: string) => void;
+}
+
+/**
+ * Split (combo) button: the left half creates a blank one immediately; the right
+ * half opens a menu of example templates that seed the composer. Shared by the
+ * Skills and Loops library toolbars.
+ */
+export function CreateWithTemplatesButton({
+ kind,
+ label,
+ onCreate,
+}: CreateWithTemplatesButtonProps) {
+ const { examples } = getCreateExamples(kind);
+ return (
+
+
+
+
+
+
+
+
+ Start from an example
+
+ {examples.map((example) => (
+ onCreate(example.prompt)}
+ >
+
+ {example.label}
+
+ {example.description}
+
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index ff2547b68..f699b1440 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -220,7 +220,8 @@ function SidebarTriggerOverlay({
const routeTitles: Record = {
"/": { title: "bb" },
"/settings": { title: "Settings" },
- "/automations": { title: "Automations" },
+ "/automations": { title: "Loops" },
+ "/skills": { title: "Skills" },
};
interface AppHeaderProps {
@@ -461,7 +462,7 @@ export function AppLayout({ children }: AppLayoutProps) {
title: "",
subtitle: undefined,
breadcrumbs: [
- { label: "Automations", to: getAutomationsRoutePath() },
+ { label: "Loops", to: getAutomationsRoutePath() },
{ label: automationName },
],
}
diff --git a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx
index 7ee31a77a..9875b2cdf 100644
--- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx
@@ -27,6 +27,8 @@ interface PromptBoxActionsMenuProps {
}
export const CREATE_LOOP_PROMPT = "Create a new bb loop to ";
+// Skill creation always targets a bb skill (the only manageable scope).
+export const CREATE_SKILL_PROMPT = "Create a new bb skill that ";
export const LOOP_PROMPT_ACTION: PromptBoxAction = {
kind: "loop",
text: CREATE_LOOP_PROMPT,
diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx
index e0693f369..c2c401f86 100644
--- a/apps/app/src/components/sidebar/AppSidebar.tsx
+++ b/apps/app/src/components/sidebar/AppSidebar.tsx
@@ -33,6 +33,7 @@ import {
import {
getAutomationsRoutePath,
getRootComposeRoutePath,
+ getSkillsRoutePath,
getThreadRoutePath,
} from "@/lib/route-paths";
import { useRouteState } from "@/hooks/useRouteState";
@@ -73,7 +74,7 @@ export function AppSidebar({
const quickCreateProject = useQuickCreateProjectController();
const navigate = useNavigate();
const closeOnMobile = useCloseMobileSidebar();
- const { isAutomationsView } = useRouteState();
+ const { isAutomationsView, isSkillsView } = useRouteState();
const { isCompactViewport, setOpen, setOpenMobile } = useSidebar();
const [desktopInfo] = useState(getBbDesktopInfo);
const [isThreadSearchActive, setIsThreadSearchActive] = useState(false);
@@ -162,6 +163,11 @@ export function AppSidebar({
void navigate(getAutomationsRoutePath());
}, [closeOnMobile, navigate]);
+ const handleOpenSkills = useCallback(() => {
+ closeOnMobile();
+ void navigate(getSkillsRoutePath());
+ }, [closeOnMobile, navigate]);
+
const handleThreadSearchKeyDown = useCallback<
KeyboardEventHandler
>(
@@ -291,6 +297,8 @@ export function AppSidebar({
>
void;
+ onOpenSkills?: () => void;
+ isSkillsActive?: boolean;
onOpenAutomations?: () => void;
isAutomationsActive?: boolean;
threadSearch?: SidebarThreadSearchInputController;
@@ -1169,6 +1171,8 @@ const SortableSidebarSection = memo(function SortableSidebarSection({
export function ProjectListActionButtons({
onNewChat,
+ onOpenSkills,
+ isSkillsActive = false,
onOpenAutomations,
isAutomationsActive = false,
threadSearch,
@@ -1252,6 +1256,23 @@ export function ProjectListActionButtons({
) : null}
)}
+ {onOpenSkills ? (
+
+ ) : null}
{onOpenAutomations ? (
) : null}
diff --git a/apps/app/src/components/ui/dialog.tsx b/apps/app/src/components/ui/dialog.tsx
index 4ca49687b..b2d720916 100644
--- a/apps/app/src/components/ui/dialog.tsx
+++ b/apps/app/src/components/ui/dialog.tsx
@@ -223,7 +223,7 @@ const DialogContent = React.forwardRef(
{...props}
>
{children}
-
+ Close
diff --git a/apps/app/src/components/ui/theme.css b/apps/app/src/components/ui/theme.css
index 89402af18..5a8e772a1 100644
--- a/apps/app/src/components/ui/theme.css
+++ b/apps/app/src/components/ui/theme.css
@@ -326,7 +326,7 @@
--card-foreground: var(--ink);
--popover: var(--canvas);
--popover-foreground: var(--ink);
- --primary: oklch(0.4891 0 0);
+ --primary: oklch(0.27 0 0);
--primary-foreground: oklch(1 0 0);
/* Subtle fill. secondary and accent share one low-emphasis step a touch more
* prominent than the card; they stay separate tokens for their distinct
@@ -519,7 +519,7 @@
--card-foreground: var(--ink);
--popover: var(--canvas);
--popover-foreground: var(--ink);
- --primary: oklch(0.7058 0 0);
+ --primary: oklch(0.82 0 0);
--primary-foreground: oklch(0.2178 0 0);
/* Subtle fill: secondary and accent share one step; muted is a distinct step
* more prominent (in light too), so the ramp reads the same in both themes. */
diff --git a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
index 671a3ba1e..fdb60fea5 100644
--- a/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
+++ b/apps/app/src/hooks/cache-owners/cache-owner-registry.test.ts
@@ -158,6 +158,10 @@ const CACHE_OWNER_QUERY_KEY_IMPORTS: CacheOwnerQueryKeyImportRegistry = {
"terminalsQueryKey",
"threadsQueryKey",
],
+ "hooks/cache-owners/skills-cache-effects.ts": [
+ "projectSkillsQueryKey",
+ "skillContentQueryKey",
+ ],
"hooks/cache-owners/system-cache-effects.ts": [
"allEnvironmentDiffFilesQueryKeyPrefix",
"allEnvironmentDiffPatchQueryKeyPrefix",
diff --git a/apps/app/src/hooks/cache-owners/skills-cache-effects.ts b/apps/app/src/hooks/cache-owners/skills-cache-effects.ts
new file mode 100644
index 000000000..90096a272
--- /dev/null
+++ b/apps/app/src/hooks/cache-owners/skills-cache-effects.ts
@@ -0,0 +1,47 @@
+import type { QueryClientArg } from "../cache-effect-types";
+import {
+ projectSkillsQueryKey,
+ skillContentQueryKey,
+} from "../queries/query-keys";
+import { invalidateQueryKeys } from "./cache-effect-utils";
+
+interface SkillContentInvalidationArg extends QueryClientArg {
+ projectId: string;
+ scope: string;
+ name: string;
+}
+
+/**
+ * Invalidate a skill's cached SKILL.md and the project skills list after an
+ * update. Centralized here so the skills mutation hooks stay off raw cache
+ * writes.
+ */
+export function invalidateSkillContentMutationQueries({
+ projectId,
+ scope,
+ name,
+ queryClient,
+}: SkillContentInvalidationArg): void {
+ invalidateQueryKeys({
+ queryClient,
+ queryKeys: [
+ skillContentQueryKey(projectId, scope, name),
+ projectSkillsQueryKey(projectId),
+ ],
+ });
+}
+
+interface ProjectSkillsInvalidationArg extends QueryClientArg {
+ projectId: string;
+}
+
+/** Invalidate the project skills list after a skill is deleted. */
+export function invalidateProjectSkillsMutationQueries({
+ projectId,
+ queryClient,
+}: ProjectSkillsInvalidationArg): void {
+ invalidateQueryKeys({
+ queryClient,
+ queryKeys: [projectSkillsQueryKey(projectId)],
+ });
+}
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index 9907ffe4e..a6426fe14 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -62,6 +62,8 @@ export const LOCAL_PATH_EXISTENCE_QUERY_KEY = "localPathExistence";
export const AUTOMATIONS_QUERY_KEY = "automations";
export const AUTOMATION_DETAIL_QUERY_KEY = "automationDetail";
export const AUTOMATION_RUNS_QUERY_KEY = "automationRuns";
+export const PROJECT_SKILLS_QUERY_KEY = "projectSkills";
+export const SKILL_CONTENT_QUERY_KEY = "skillContent";
export interface ThreadListQueryFilters {
projectId?: string;
hasParent?: ThreadListFilters["hasParent"];
@@ -1053,3 +1055,15 @@ export function allAutomationDetailQueryKeyPrefix(): AllAutomationDetailQueryKey
export function allAutomationRunsQueryKeyPrefix(): AllAutomationRunsQueryKeyPrefix {
return [AUTOMATION_RUNS_QUERY_KEY];
}
+
+export function projectSkillsQueryKey(projectId: string) {
+ return [PROJECT_SKILLS_QUERY_KEY, projectId] as const;
+}
+
+export function skillContentQueryKey(
+ projectId: string,
+ scope: string,
+ name: string,
+) {
+ return [SKILL_CONTENT_QUERY_KEY, projectId, scope, name] as const;
+}
diff --git a/apps/app/src/hooks/queries/skills-queries.ts b/apps/app/src/hooks/queries/skills-queries.ts
new file mode 100644
index 000000000..789fd02be
--- /dev/null
+++ b/apps/app/src/hooks/queries/skills-queries.ts
@@ -0,0 +1,89 @@
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+import type {
+ DeleteSkillRequest,
+ SkillSummary,
+ UpdateSkillRequest,
+} from "@bb/server-contract";
+import * as api from "@/lib/api";
+import {
+ projectSkillsQueryKey,
+ skillContentQueryKey,
+ SKILL_CONTENT_QUERY_KEY,
+} from "@/hooks/queries/query-keys";
+import {
+ invalidateProjectSkillsMutationQueries,
+ invalidateSkillContentMutationQueries,
+} from "@/hooks/cache-owners/skills-cache-effects";
+
+/**
+ * Skills discovered for a project's default workspace (user/builtin/provider
+ * scopes plus that project's `.bb/skills`). The top-level Skills page passes the
+ * personal project so it surfaces the user's global skill set.
+ */
+export function useProjectSkills(projectId: string) {
+ return useQuery({
+ queryKey: projectSkillsQueryKey(projectId),
+ queryFn: ({ signal }) =>
+ api.listProjectSkills({ projectId, environmentId: null, signal }),
+ enabled: projectId.length > 0,
+ // Skills are on-disk files mutated out-of-band — agents write SKILL.md, and
+ // users edit them in their own editor (the detail view's "Open in editor").
+ // Always re-read from disk on mount/focus so the list never shows a stale set.
+ staleTime: 0,
+ refetchOnMount: "always",
+ });
+}
+
+/** Read a skill's SKILL.md (lazily; only when a skill is selected). */
+export function useSkillContent(
+ projectId: string,
+ skill: SkillSummary | null,
+) {
+ return useQuery({
+ queryKey: skill
+ ? skillContentQueryKey(projectId, skill.scope, skill.name)
+ : [SKILL_CONTENT_QUERY_KEY, projectId, "none"],
+ queryFn: ({ signal }) =>
+ api.getSkillContent({
+ projectId,
+ scope: skill!.scope,
+ name: skill!.name,
+ environmentId: null,
+ signal,
+ }),
+ enabled: skill !== null && projectId.length > 0,
+ // Re-read SKILL.md from disk every time the detail view opens or regains
+ // focus — the user may have just edited the file in their own editor.
+ staleTime: 0,
+ refetchOnMount: "always",
+ });
+}
+
+export function useUpdateSkill(projectId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ meta: { errorMessage: "Failed to save skill." },
+ mutationFn: (body: UpdateSkillRequest) =>
+ api.updateSkillContent(projectId, body),
+ onSuccess: (_data, variables) => {
+ invalidateSkillContentMutationQueries({
+ projectId,
+ scope: variables.scope,
+ name: variables.name,
+ queryClient,
+ });
+ },
+ });
+}
+
+export function useDeleteSkill(projectId: string) {
+ const queryClient = useQueryClient();
+ return useMutation({
+ meta: { errorMessage: "Failed to delete skill." },
+ mutationFn: (body: DeleteSkillRequest) =>
+ api.deleteProjectSkill(projectId, body),
+ onSuccess: () => {
+ invalidateProjectSkillsMutationQueries({ projectId, queryClient });
+ },
+ });
+}
diff --git a/apps/app/src/hooks/useRouteState.ts b/apps/app/src/hooks/useRouteState.ts
index 63a6c9ea6..210f03d3d 100644
--- a/apps/app/src/hooks/useRouteState.ts
+++ b/apps/app/src/hooks/useRouteState.ts
@@ -19,6 +19,8 @@ export interface RouteState {
isAutomationsView: boolean;
/** On an automation detail page ("/automations/:projectId/:automationId"). */
isAutomationDetailView: boolean;
+ /** On the Skills surface ("/skills"). */
+ isSkillsView: boolean;
/** ID of the automation in view (automation detail only), else undefined. */
automationId: string | undefined;
/** Owning project of the automation in view (automation detail only). */
@@ -90,6 +92,7 @@ export function useRouteState(): RouteState {
isAutomationsView:
location.pathname === "/automations" || Boolean(automationDetailMatch),
isAutomationDetailView: Boolean(automationDetailMatch),
+ isSkillsView: location.pathname === "/skills",
automationId: automationDetailMatch?.params.automationId,
automationProjectId: automationDetailMatch?.params.projectId,
isRootView,
diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts
index b8175daf5..e1f8a0cd1 100644
--- a/apps/app/src/lib/api.ts
+++ b/apps/app/src/lib/api.ts
@@ -17,6 +17,12 @@ import type {
AutomationRunListResponse,
AutomationRunResponse,
AutomationsOverviewResponse,
+ UpdateAutomationRequest,
+ SkillListResponse,
+ DeleteSkillRequest,
+ SkillContentResponse,
+ UpdateSkillRequest,
+ SkillScope,
CommandListResponse,
CreateProjectSourceRequest,
CreateProjectRequest,
@@ -673,6 +679,91 @@ export async function deleteAutomation({
);
}
+export async function updateAutomation({
+ projectId,
+ automationId,
+ patch,
+}: AutomationRef & { patch: UpdateAutomationRequest }): Promise {
+ return request(
+ apiClient.projects[":id"].automations[":automationId"].$patch({
+ param: { id: projectId, automationId },
+ json: patch,
+ }),
+ );
+}
+
+interface ListProjectSkillsArgs {
+ projectId: string;
+ environmentId: string | null;
+ signal?: AbortSignal;
+}
+
+export async function listProjectSkills({
+ projectId,
+ environmentId,
+ signal,
+}: ListProjectSkillsArgs): Promise {
+ return request(
+ apiClient.projects[":id"].skills.$get(
+ {
+ param: { id: projectId },
+ query: { environmentId: environmentId ?? "" },
+ },
+ requestOptions(signal),
+ ),
+ );
+}
+
+export async function deleteProjectSkill(
+ projectId: string,
+ body: DeleteSkillRequest,
+): Promise {
+ await requestVoid(
+ apiClient.projects[":id"].skills.$delete({
+ param: { id: projectId },
+ json: body,
+ }),
+ );
+}
+
+interface GetSkillContentArgs {
+ projectId: string;
+ scope: SkillScope;
+ name: string;
+ environmentId: string | null;
+ signal?: AbortSignal;
+}
+
+export async function getSkillContent({
+ projectId,
+ scope,
+ name,
+ environmentId,
+ signal,
+}: GetSkillContentArgs): Promise {
+ return request(
+ apiClient.projects[":id"].skills.content.$get(
+ {
+ param: { id: projectId },
+ query: { scope, name, environmentId: environmentId ?? "" },
+ },
+ requestOptions(signal),
+ ),
+ );
+}
+
+export async function updateSkillContent(
+ projectId: string,
+ body: UpdateSkillRequest,
+): Promise<{ filePath: string }> {
+ return request<{ filePath: string }>(
+ apiClient.projects[":id"].skills.content.$patch({
+ param: { id: projectId },
+ json: body,
+ }),
+ );
+}
+
export async function addProjectSource(
projectId: string,
req: CreateProjectSourceRequest,
diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts
index 68d0ab552..cd01a2657 100644
--- a/apps/app/src/lib/route-paths.ts
+++ b/apps/app/src/lib/route-paths.ts
@@ -18,6 +18,7 @@ export const SETTINGS_ROUTE_PATH = "/settings";
export const AUTOMATIONS_ROUTE_PATH = "/automations";
export const AUTOMATION_DETAIL_ROUTE_PATH =
"/automations/:projectId/:automationId";
+export const SKILLS_ROUTE_PATH = "/skills";
export const ROOT_COMPOSE_ROUTE_PATH = APP_ROOT_ROUTE_PATH;
export const LEGACY_PROJECT_COMPOSE_ROUTE_PATH = "/projects/:projectId";
export const PROJECTLESS_ARCHIVED_ROUTE_PATH = "/archived";
@@ -65,6 +66,10 @@ export function getAutomationsRoutePath(): string {
return AUTOMATIONS_ROUTE_PATH;
}
+export function getSkillsRoutePath(): string {
+ return SKILLS_ROUTE_PATH;
+}
+
export interface AutomationDetailRoutePathArgs {
projectId: string;
automationId: string;
diff --git a/apps/app/src/views/SkillsView.stories.tsx b/apps/app/src/views/SkillsView.stories.tsx
new file mode 100644
index 000000000..e2aca413d
--- /dev/null
+++ b/apps/app/src/views/SkillsView.stories.tsx
@@ -0,0 +1,145 @@
+import { useState } from "react";
+import type { SkillSummary } from "@bb/server-contract";
+import {
+ SkillDetailDialogView,
+ SkillsOverview,
+ type SkillsOverviewProps,
+} from "./SkillsView";
+
+export default {
+ title: "Skills",
+};
+
+function makeSkill(overrides: Partial = {}): SkillSummary {
+ return {
+ name: "code-review",
+ description: "Review the current diff against our conventions.",
+ provider: "claude-code",
+ scope: "claude-user",
+ filePath: "/home/u/.claude/skills/code-review/SKILL.md",
+ manageable: false,
+ ...overrides,
+ };
+}
+
+// Every bb install ships these two built-in bb skills, so they're always present
+// (read-only — built-ins aren't user-managed).
+const defaultBbSkills: SkillSummary[] = [
+ makeSkill({ name: "bb-cli", provider: null, scope: "bb-builtin", description: "Inspect and orchestrate bb from the CLI." }),
+ makeSkill({ name: "skill-creator", provider: null, scope: "bb-builtin", description: "Author new bb skills." }),
+];
+
+// Provider skills a developer might also have installed (read-only, per provider).
+const providerSkills: SkillSummary[] = [
+ makeSkill({ name: "branch", description: "Create a branch, commit, and open a PR." }),
+ makeSkill({ name: "moss-notes", description: "Author and edit Moss notes." }),
+ makeSkill({ name: "deep-research", provider: "codex", scope: "codex", description: "Fan-out web research with citations." }),
+];
+
+// User-created (manageable) bb skills.
+const bbSkills: SkillSummary[] = [
+ makeSkill({ name: "repro-and-fix", provider: null, scope: "bb-user", manageable: true, description: "Turn a bug report into a failing test, then a fix." }),
+ makeSkill({ name: "scaffold-component", provider: null, scope: "bb-user", manageable: true, description: "Scaffold a component, test, and story to our patterns." }),
+];
+
+const NOOP = () => {};
+
+// Sample SKILL.md shown in the popup when a row is clicked. Exercises the shared
+// markdown viewer (heading, list, code, table, blockquote) and the frontmatter
+// strip — the body should start at "Code review", not the YAML.
+const SAMPLE_SKILL_MD = `---
+name: code-review
+description: Review the current diff against our conventions.
+---
+
+# Code review
+
+Review the **current working diff** for correctness and clarity. Lead with the
+highest-severity findings; skip nits unless asked.
+
+## When to use
+
+- Before opening a PR, or when the user asks to "review my changes".
+- Not for whole-repo audits — scope to the diff.
+
+## Steps
+
+1. Run \`git diff\` and read every hunk.
+2. Group findings by severity, and cite each as \`file:line\`.
+
+\`\`\`ts
+// Flag anything that mutates shared state without a lock.
+if (!resolution.additionalSkillsRootPaths) throw new Error("unresolved");
+\`\`\`
+
+| Severity | Meaning | Action |
+| -------- | -------------- | --------------- |
+| P0 | Correctness | Block the merge |
+| P1 | Latent risk | Flag, recommend |
+| P2 | Style / polish | Optional |
+
+> Summary first — what's wrong and where — then the detail.
+`;
+
+// Mirror SkillsView's rule: only manageable bb user/project skills expose inline
+// Edit + Delete; everything else is read-only.
+function storyCanManage(skill: SkillSummary | null): boolean {
+ return (
+ skill?.manageable === true &&
+ (skill.scope === "bb-user" || skill.scope === "bb-project")
+ );
+}
+
+// Clicking a row opens the actual detail popup (SkillDetailDialogView) seeded
+// with a sample SKILL.md — the production interaction, minus the live
+// content/save/delete queries. Shared across stories so every state can open it.
+function Story(props: Partial) {
+ const [selected, setSelected] = useState(null);
+ return (
+
+
+ setSelected(null)}
+ onSave={() => Promise.resolve(true)}
+ onDelete={() => setSelected(null)}
+ onOpenInEditor={NOOP}
+ />
+
+ );
+}
+
+// Has user-created bb skills — no teaching, full provider-grouped list. Type in
+// the search to exercise filtering and the no-match state; click any row to open
+// the detail popup (manageable skills show Edit/Delete, the rest are read-only).
+export function Overview() {
+ return ;
+}
+
+// The true minimum every install starts at: just the two default bb skills, plus
+// the teaching (no user-created skills yet).
+export function Empty() {
+ return ;
+}
+
+export function Loading() {
+ return ;
+}
+
+export function Error() {
+ return ;
+}
diff --git a/apps/app/src/views/SkillsView.test.tsx b/apps/app/src/views/SkillsView.test.tsx
new file mode 100644
index 000000000..206c07bb5
--- /dev/null
+++ b/apps/app/src/views/SkillsView.test.tsx
@@ -0,0 +1,88 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import type { SkillSummary } from "@bb/server-contract";
+import { describe, expect, it } from "vitest";
+import { SkillsOverview, groupSkillsByProvider } from "./SkillsView";
+
+function makeSkill(overrides: Partial = {}): SkillSummary {
+ return {
+ name: "code-review",
+ description: "Review the current diff.",
+ provider: "claude-code",
+ scope: "claude-user",
+ filePath: "/home/u/.claude/skills/code-review/SKILL.md",
+ manageable: false,
+ ...overrides,
+ };
+}
+
+function render(props: Partial[0]>): string {
+ return renderToStaticMarkup(
+ {})}
+ onSelectSkill={props.onSelectSkill ?? (() => {})}
+ onRetry={props.onRetry}
+ />,
+ );
+}
+
+describe("groupSkillsByProvider", () => {
+ it("groups by provider with bb-agnostic skills first", () => {
+ const groups = groupSkillsByProvider([
+ makeSkill({ name: "claude-skill", provider: "claude-code" }),
+ makeSkill({ name: "bb-skill", provider: null, scope: "bb-user" }),
+ makeSkill({ name: "codex-skill", provider: "codex", scope: "codex" }),
+ ]);
+ expect(groups.map((g) => g.key)).toEqual(["bb", "claude-code", "codex"]);
+ expect(groups[0]?.label).toBe("bb");
+ });
+});
+
+describe("SkillsOverview", () => {
+ it("renders provider groups with calm name + description rows", () => {
+ const markup = render({
+ skills: [
+ makeSkill({ name: "claude-skill", provider: "claude-code" }),
+ makeSkill({ name: "bb-skill", provider: null, scope: "bb-user" }),
+ ],
+ });
+ expect(markup).toContain("claude-skill");
+ expect(markup).toContain("Review the current diff.");
+ // bb group header renders, and bb comes before the provider group
+ expect(markup).toContain(">bb<");
+ expect(markup.indexOf("bb-skill")).toBeLessThan(
+ markup.indexOf("claude-skill"),
+ );
+ // collapsible section headers, expanded by default
+ expect(markup).toContain('aria-expanded="true"');
+ });
+
+ it("renders a New bb skill create action", () => {
+ expect(render({ skills: [] })).toContain("New bb skill");
+ });
+
+ it("keeps create out of the list (templates live in the menu, not a panel)", () => {
+ // The page is never truly empty (built-ins always ship), so there is no
+ // persistent teaching panel; the create templates live in the closed menu.
+ const markup = render({ skills: [] });
+ expect(markup).toContain("New bb skill");
+ expect(markup).not.toContain("Start from an example");
+ });
+
+ it("shows a loading skeleton", () => {
+ const markup = render({ skills: [], isLoading: true });
+ expect(markup).toContain('aria-label="Loading skills"');
+ expect(markup).toContain("animate-pulse");
+ expect(markup).not.toContain("Start from an example");
+ });
+
+ it("shows a recoverable error state with a retry", () => {
+ const markup = render({ skills: [], hasError: true, onRetry: () => {} });
+ // Apostrophe is HTML-escaped in static markup, so match the stable fragment.
+ expect(markup).toContain("load skills.");
+ expect(markup).toContain("Retry");
+ expect(markup).toContain('role="alert"');
+ });
+});
diff --git a/apps/app/src/views/SkillsView.tsx b/apps/app/src/views/SkillsView.tsx
new file mode 100644
index 000000000..5342628ed
--- /dev/null
+++ b/apps/app/src/views/SkillsView.tsx
@@ -0,0 +1,672 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useNavigate } from "react-router-dom";
+import { PERSONAL_PROJECT_ID } from "@bb/domain";
+import type { SkillProvider, SkillSummary } from "@bb/server-contract";
+import { Button } from "@/components/ui/button.js";
+import {
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog.js";
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu.js";
+import { EmptyStatePanel } from "@/components/ui/empty-state.js";
+import { Skeleton } from "@/components/ui/skeleton.js";
+import { FilePreview } from "@/components/secondary-panel/FilePreview.js";
+import { Icon } from "@/components/ui/icon.js";
+import { PageShell } from "@/components/ui/page-shell.js";
+import { Pill } from "@/components/ui/pill.js";
+import { CREATE_SKILL_PROMPT } from "@/components/promptbox/PromptBoxActionsMenu";
+import { CreateWithTemplatesButton } from "@/components/create-via-prompt-examples";
+import { getProviderIconInfo } from "@/lib/provider-icon";
+import { getRootComposeRoutePath } from "@/lib/route-paths";
+import { cn } from "@/lib/utils";
+import {
+ useDeleteSkill,
+ useProjectSkills,
+ useSkillContent,
+ useUpdateSkill,
+} from "@/hooks/queries/skills-queries";
+import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets";
+
+interface SkillProviderGroup {
+ /** Group key: the provider id, or "bb" for provider-agnostic bb skills. */
+ key: string;
+ label: string;
+ providerId: SkillProvider | null;
+ skills: SkillSummary[];
+}
+
+// bb-agnostic skills first, then each provider.
+const PROVIDER_ORDER: readonly (SkillProvider | null)[] = [
+ null,
+ "claude-code",
+ "codex",
+];
+
+function providerLabel(providerId: SkillProvider | null): string {
+ if (providerId === null) {
+ return "bb";
+ }
+ return getProviderIconInfo(providerId)?.ariaLabel ?? providerId;
+}
+
+/**
+ * Group skills by the provider surface they're discovered under. bb-agnostic
+ * skills (`provider: null`) collapse into a single "bb" group, listed last.
+ */
+export function groupSkillsByProvider(
+ skills: readonly SkillSummary[],
+): SkillProviderGroup[] {
+ const byKey = new Map();
+ for (const skill of skills) {
+ const key = skill.provider ?? "bb";
+ const existing = byKey.get(key);
+ if (existing) {
+ existing.skills.push(skill);
+ continue;
+ }
+ byKey.set(key, {
+ key,
+ label: providerLabel(skill.provider),
+ providerId: skill.provider,
+ skills: [skill],
+ });
+ }
+ return PROVIDER_ORDER.flatMap((provider) => {
+ const group = byKey.get(provider ?? "bb");
+ return group ? [group] : [];
+ });
+}
+
+function ProviderLogo({
+ providerId,
+ className,
+}: {
+ providerId: SkillProvider;
+ className?: string;
+}) {
+ const info = getProviderIconInfo(providerId);
+ if (!info) {
+ return null;
+ }
+ const LogoIcon = info.icon;
+ return ;
+}
+
+/** Calm, typeahead-style row: skill icon + name + muted description. Clicking
+ * views the skill. */
+function SkillRow({
+ skill,
+ onSelect,
+}: {
+ skill: SkillSummary;
+ onSelect: () => void;
+}) {
+ return (
+
+ );
+}
+
+export interface SkillsOverviewProps {
+ skills: readonly SkillSummary[];
+ isLoading: boolean;
+ hasError: boolean;
+ /** Opens the composer to create a skill, optionally seeded with a full prompt. */
+ onCreateSkill: (prompt?: string) => void;
+ onSelectSkill: (skill: SkillSummary) => void;
+ /** Refetch after a load failure — gives the error state a way out. */
+ onRetry?: () => void;
+}
+
+/**
+ * Presentational Skills list: provider-grouped, searchable, typeahead-style
+ * rows. Split from the data-fetching container so it renders in tests/stories.
+ */
+export function SkillsOverview({
+ skills,
+ isLoading,
+ hasError,
+ onCreateSkill,
+ onSelectSkill,
+ onRetry,
+}: SkillsOverviewProps) {
+ const [query, setQuery] = useState("");
+ const [collapsed, setCollapsed] = useState>(new Set());
+ const toggleGroup = useCallback((key: string) => {
+ setCollapsed((prev) => {
+ const next = new Set(prev);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ }, []);
+ const normalizedQuery = query.trim().toLowerCase();
+ const groups = useMemo(() => {
+ const filtered = skills.filter(
+ (skill) =>
+ normalizedQuery === "" ||
+ skill.name.toLowerCase().includes(normalizedQuery) ||
+ (skill.description ?? "").toLowerCase().includes(normalizedQuery),
+ );
+ return groupSkillsByProvider(filtered);
+ }, [skills, normalizedQuery]);
+
+ return (
+
+
+ {/* One library of every skill across providers. You search and manage
+ here; creating a bb skill is a single template-based action, the way
+ VS Code / Raycast keep authoring out of the management list rather
+ than stacking a teaching panel onto a list that is never empty. */}
+
+
+ Every skill your agents can run, grouped by provider. bb skills run
+ across all of them.
+