From 42229e2a12173ff474dda6941ab5e24423f5d2ac Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Thu, 25 Jun 2026 17:20:02 -0700 Subject: [PATCH] Redesign the Skills page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Skills library — a provider-grouped, searchable list of every skill agents can run — with the backend (host.list_skills / write_skill / delete_skill + the skill-listing service) and a SkillsView. The detail popup renders SKILL.md through the app's real file viewer (FilePreview): markdown, frontmatter metadata, code blocks, GFM tables. A single overflow menu by the title (Edit / Open in editor / Delete); editing happens inline with Cancel/Save in a modal footer. Quiet page-description caption, pointer cursors on the rows and the dialog close, and a recoverable error state (Couldn't load skills. + Retry). Scoped to the Skills page only; the Loops/Automations redesign, the agent-loop edit composer, and the created-loop timeline notice land in later commits. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/app/src/App.tsx | 7 + .../components/create-via-prompt-examples.tsx | 214 ++++++ apps/app/src/components/layout/AppLayout.tsx | 5 +- .../promptbox/PromptBoxActionsMenu.tsx | 2 + .../app/src/components/sidebar/AppSidebar.tsx | 10 +- .../src/components/sidebar/ProjectList.tsx | 26 +- apps/app/src/components/ui/dialog.tsx | 2 +- apps/app/src/components/ui/theme.css | 4 +- .../cache-owners/cache-owner-registry.test.ts | 4 + .../cache-owners/skills-cache-effects.ts | 47 ++ apps/app/src/hooks/queries/query-keys.ts | 14 + apps/app/src/hooks/queries/skills-queries.ts | 89 +++ apps/app/src/hooks/useRouteState.ts | 3 + apps/app/src/lib/api.ts | 91 +++ apps/app/src/lib/route-paths.ts | 5 + apps/app/src/views/SkillsView.stories.tsx | 145 ++++ apps/app/src/views/SkillsView.test.tsx | 88 +++ apps/app/src/views/SkillsView.tsx | 672 ++++++++++++++++++ apps/host-daemon/src/command-discovery.ts | 131 ++++ apps/host-daemon/src/command-dispatch.ts | 8 + .../src/command-handlers/list-commands.ts | 2 +- .../src/command-handlers/list-skills.test.ts | 307 ++++++++ .../src/command-handlers/list-skills.ts | 279 ++++++++ apps/server/src/routes/projects.ts | 83 +++ .../src/services/skills/skill-listing.ts | 232 ++++++ .../test/public/public-project-skills.test.ts | 253 +++++++ apps/server/test/skills/skill-listing.test.ts | 159 +++++ packages/host-daemon-contract/src/commands.ts | 143 ++++ packages/host-daemon-contract/src/session.ts | 3 + .../test/contract.test.ts | 16 + packages/server-contract/src/api/projects.ts | 104 +++ packages/server-contract/src/public-api.ts | 42 ++ 32 files changed, 3181 insertions(+), 9 deletions(-) create mode 100644 apps/app/src/components/create-via-prompt-examples.tsx create mode 100644 apps/app/src/hooks/cache-owners/skills-cache-effects.ts create mode 100644 apps/app/src/hooks/queries/skills-queries.ts create mode 100644 apps/app/src/views/SkillsView.stories.tsx create mode 100644 apps/app/src/views/SkillsView.test.tsx create mode 100644 apps/app/src/views/SkillsView.tsx create mode 100644 apps/host-daemon/src/command-handlers/list-skills.test.ts create mode 100644 apps/host-daemon/src/command-handlers/list-skills.ts create mode 100644 apps/server/src/services/skills/skill-listing.ts create mode 100644 apps/server/test/public/public-project-skills.test.ts create mode 100644 apps/server/test/skills/skill-listing.test.ts 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. +

+
+
+ + setQuery(event.target.value)} + autoComplete="off" + className="min-w-0 flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground" + /> +
+ +
+
+ {hasError ? ( + // Failure is direction, not a dead end: say what happened plainly and + // offer the way out, kept calm rather than alarmist. + +
+ Couldn't load skills. + {onRetry ? ( + + ) : null} +
+
+ ) : isLoading ? ( +
+ {[ + ["w-28", "w-48"], + ["w-36", "w-40"], + ["w-24", "w-56"], + ["w-32", "w-44"], + ["w-40", "w-52"], + ["w-28", "w-44"], + ].map(([nameWidth, descWidth]) => ( +
+ + + +
+ ))} +
+ ) : ( + <> + {groups.length === 0 ? ( + normalizedQuery === "" ? null : ( + + {`No skills match "${query}"`} + + ) + ) : ( +
+ {groups.map((group) => { + const isCollapsed = collapsed.has(group.key); + return ( +
+ + {isCollapsed ? null : ( +
+
+ {group.skills.map((skill) => ( + onSelectSkill(skill)} + /> + ))} +
+
+ )} +
+ ); + })} +
+ )} + + )} +
+
+ ); +} + +const SCOPE_LABELS: Record = { + "bb-builtin": "bb · built-in", + "bb-user": "bb · user", + "bb-project": "bb · project", + "claude-user": "Claude · user", + "claude-project": "Claude · project", + codex: "Codex", + plugin: "Plugin", +}; + +export interface SkillDetailDialogViewProps { + skill: SkillSummary | null; + /** Already-fetched SKILL.md source. */ + content: string; + isLoadingContent: boolean; + isContentError: boolean; + /** Expose inline Edit + Delete (manageable bb user/project skills only). */ + canManage: boolean; + canOpenInEditor: boolean; + isSaving: boolean; + isDeleting: boolean; + onClose: () => void; + /** + * Persist edited content. Resolves `true` when the save succeeded so the view + * leaves edit mode; `false` keeps the draft for retry. + */ + onSave: (content: string) => Promise; + onDelete: () => void; + onOpenInEditor: () => void; +} + +/** + * Presentational skill detail popup: renders the SKILL.md (read) or an inline + * editor, with Edit / Delete / Open-in-editor affordances. Owns only local UI + * state (editing, draft, delete confirmation); all data + persistence arrive as + * props so it renders in stories/tests without queries. The connected + * {@link SkillDetailDialog} wires it to the content/update/delete queries. + */ +export function SkillDetailDialogView({ + skill, + content, + isLoadingContent, + isContentError, + canManage, + canOpenInEditor, + isSaving, + isDeleting, + onClose, + onSave, + onDelete, + onOpenInEditor, +}: SkillDetailDialogViewProps) { + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState(""); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + useEffect(() => { + setEditing(false); + setConfirmingDelete(false); + }, [skill?.scope, skill?.name, skill?.provider]); + + async function handleSave() { + if (await onSave(draft)) { + setEditing(false); + } + } + + const textareaRef = useRef(null); + // Focus the editor the moment editing starts, so it's truly edit-in-place. + useEffect(() => { + if (editing) { + textareaRef.current?.focus(); + } + }, [editing]); + + function startEditing() { + setConfirmingDelete(false); + setDraft(content); + setEditing(true); + } + + // Read mode: a single overflow by the title (Notion/Linear-style). Edit is + // reached from here and happens inline; the viewer has no toolbar. + const overflowMenu = + canManage || canOpenInEditor ? ( + + + + + + {canManage ? ( + + + Edit + + ) : null} + {canOpenInEditor ? ( + + + Open in editor + + ) : null} + {canManage ? ( + <> + + setConfirmingDelete(true)} + > + + Delete + + + ) : null} + + + ) : null; + + // Edit (Cancel/Save) and delete-confirm actions live in the modal footer. + const footerActions = editing ? ( + <> + + + + ) : confirmingDelete ? ( + <> + + Delete this skill? + + + + + ) : null; + + return ( + { + if (!open) onClose(); + }} + > + + + {/* Title + a single overflow by it; pr-7 keeps the row clear of the + dialog's ✕ close. */} +
+ + + {skill?.name} + {skill?.provider ? ( + + ) : null} + {skill ? ( + + {SCOPE_LABELS[skill.scope]} + + ) : null} + +
+ {editing || confirmingDelete ? null : overflowMenu} +
+
+
+ + {isContentError ? ( +

Failed to load the skill.

+ ) : isLoadingContent ? ( +

Loading…

+ ) : editing ? ( +