diff --git a/apps/app/src/App.tsx b/apps/app/src/App.tsx
index 858671fb1..3afdd9c01 100644
--- a/apps/app/src/App.tsx
+++ b/apps/app/src/App.tsx
@@ -19,6 +19,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,
@@ -53,6 +54,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,
@@ -110,6 +116,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..e1ce77194
--- /dev/null
+++ b/apps/app/src/components/create-via-prompt-examples.tsx
@@ -0,0 +1,212 @@
+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_SKILL_PROMPT } from "@/components/promptbox/PromptBoxActionsMenu";
+import { CREATE_LOOP_PROMPT } from "@/lib/loop-prompt";
+
+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 b6ad7d883..92f5b8e43 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -221,7 +221,8 @@ function SidebarTriggerOverlay({
const routeTitles: Record = {
"/": { title: "bb" },
"/settings": { title: "Settings" },
- "/automations": { title: "Automations" },
+ "/automations": { title: "Loops" },
+ "/skills": { title: "Skills" },
};
interface AppHeaderProps {
@@ -455,7 +456,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 09135ed18..aaeeff4c4 100644
--- a/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx
+++ b/apps/app/src/components/promptbox/PromptBoxActionsMenu.tsx
@@ -26,6 +26,9 @@ interface PromptBoxActionsMenuProps {
onAction: (action: PromptBoxAction) => void;
}
+// Skill creation always targets a bb skill (the only manageable scope). The
+// loop-prompt constant lives in `@/lib/loop-prompt` (main's refactor).
+export const CREATE_SKILL_PROMPT = "Create a new bb skill that ";
export const LOOP_PROMPT_ACTION: PromptBoxAction = {
kind: "loop",
command: { trigger: "/", name: "loop", trailingText: " " },
diff --git a/apps/app/src/components/promptbox/useComposerArea.ts b/apps/app/src/components/promptbox/useComposerArea.ts
new file mode 100644
index 000000000..c7303238e
--- /dev/null
+++ b/apps/app/src/components/promptbox/useComposerArea.ts
@@ -0,0 +1,518 @@
+import {
+ useCallback,
+ useMemo,
+ useState,
+ type Dispatch,
+ type SetStateAction,
+} from "react";
+import type { PermissionMode, PromptTextMention } from "@bb/domain";
+import type {
+ CreateExecutionInputSources,
+ ExistingThreadExecutionInputSources,
+} from "@bb/server-contract";
+import {
+ type AttachmentsConfig,
+ type PromptBoxAction,
+ type TypeaheadConfig,
+} from "@/components/promptbox/PromptBoxInternal";
+import type { PromptMentionLinkResolver } from "@/components/promptbox/editor/prompt-mention-link";
+import {
+ type ExecutionControlsProps,
+ type ExecutionPermissionConfig,
+} from "@/components/promptbox/ExecutionControls";
+import { buildProviderPromptActionProps } from "@/components/promptbox/mentions/command-trigger";
+import { withLoopPromptAction } from "@/components/promptbox/PromptBoxActionsMenu";
+import {
+ useThreadCreationOptions,
+ type UseThreadCreationOptionsResult,
+} from "@/hooks/useThreadCreationOptions";
+import type {
+ ScopedExecutionInputSources,
+ UseComponentLocalCreationOptions,
+ UseNewThreadCreationOptions,
+} from "@/hooks/thread-creation-options/selection-state";
+import { useCommandSuggestions } from "@/hooks/useCommandSuggestions";
+import {
+ usePromptMentions,
+ type UsePromptMentionsOptions,
+} from "@/hooks/usePromptMentions";
+import {
+ usePromptDraftStorage,
+ type PromptDraftScope,
+} from "@/hooks/usePromptDraftStorage";
+import { useUploadPromptAttachment } from "@/hooks/mutations/project-mutations";
+import { promptDraftToInput, type PromptDraftState } from "@/lib/prompt-draft";
+import { getMutationErrorMessage } from "@/lib/mutation-errors";
+
+const noop = () => {};
+
+/**
+ * How a prompt-box footer wires its execution controls. Read-only footers (the
+ * side chat, which inherits the parent thread's model) render the SAME pickers
+ * disabled, so every onChange is a no-op. New-thread compose lets the user
+ * switch providers; committed-thread follow-ups keep the provider locked.
+ */
+export interface ComposerAreaExecutionConfig {
+ /** Render execution controls read-only: every onChange becomes a no-op. */
+ readOnly?: boolean;
+ /**
+ * Wire the provider picker's onChange to the live setter. Only the new-thread
+ * composer switches providers; follow-ups and side chats leave it locked.
+ */
+ providerSwitchable?: boolean;
+}
+
+/**
+ * Permission picker shaping, matching the three existing surfaces exactly:
+ * - `editable` — new-thread compose (plain value + setter).
+ * - `editable-gated` — follow-up, where the value/options are suppressed until
+ * the thread's resolved execution defaults arrive (`resolved`).
+ * - `read-only` — side chat, pinned to a constant with a no-op onChange.
+ */
+export type ComposerAreaPermissionConfig =
+ | { kind: "editable" }
+ | { kind: "editable-gated"; resolved: boolean }
+ | { kind: "read-only"; value: PermissionMode };
+
+export interface ComposerAreaCommandsConfig {
+ projectId: string | undefined;
+ /**
+ * Provider whose commands are discovered. Pass the committed thread's provider
+ * for follow-ups/side chats; omit to follow the live provider picker (the
+ * new-thread composer, where the user can switch providers).
+ */
+ providerId?: string;
+ /**
+ * Environment that scopes command discovery. Committed threads pass a static
+ * id; the new-thread composer derives it from the resolved environment, which
+ * is itself produced here — so it may instead be a resolver that receives the
+ * live `environmentSelectionValue`.
+ */
+ environmentId:
+ | string
+ | null
+ | ((environmentSelectionValue: string) => string | null);
+}
+
+export interface ComposerAreaAttachmentsConfig {
+ projectId: string;
+ /**
+ * How an upload failure is surfaced. `collect` (default) attempts every file
+ * and lists the failed names; `first` stops at the first failure and shows
+ * that mutation's error message.
+ */
+ errorMode?: "collect" | "first";
+}
+
+export type ComposerAreaMentionsConfig =
+ | UsePromptMentionsOptions
+ | ((environmentSelectionValue: string) => UsePromptMentionsOptions);
+
+interface UseComposerAreaBaseOptions {
+ draftScope: PromptDraftScope;
+ /** Project scope for `@`-mention discovery (`undefined` for projectless). */
+ mentionsProjectId: string | undefined;
+ mentions: ComposerAreaMentionsConfig;
+ commands: ComposerAreaCommandsConfig;
+ resolveMentionLink: PromptMentionLinkResolver;
+ attachments: ComposerAreaAttachmentsConfig;
+ execution: ComposerAreaExecutionConfig;
+ permission: ComposerAreaPermissionConfig;
+}
+
+export interface UseComposerAreaComponentLocalOptions
+ extends UseComposerAreaBaseOptions {
+ creationOptions: UseComponentLocalCreationOptions;
+}
+
+export interface UseComposerAreaNewThreadOptions
+ extends UseComposerAreaBaseOptions {
+ creationOptions: UseNewThreadCreationOptions;
+}
+
+/**
+ * Draft-derived composer values shared by every prompt box. The submit handlers
+ * and submit/placeholder state stay per-site (they differ by surface), so this
+ * exposes the draft, the change handler, and the parsed input the site needs to
+ * build its own composer config.
+ */
+export interface ComposerAreaComposer {
+ message: string;
+ mentionRanges: readonly PromptTextMention[];
+ onChangeMessage: (value: string, mentionRanges: PromptTextMention[]) => void;
+ currentPromptDraft: PromptDraftState;
+ currentPromptDraftInput: ReturnType;
+ hasPromptDraftInput: boolean;
+}
+
+export interface UseComposerAreaResult {
+ threadCreationOptions: UseThreadCreationOptionsResult;
+ promptDraft: ReturnType;
+ promptActions: readonly PromptBoxAction[];
+ providerPromptActionProps: { promptActions: readonly PromptBoxAction[] };
+ executionConfig: ExecutionControlsProps;
+ permissionConfig: ExecutionPermissionConfig;
+ typeaheadConfig: TypeaheadConfig;
+ attachmentsConfig: AttachmentsConfig;
+ composer: ComposerAreaComposer;
+ attachmentError: string | null;
+ setAttachmentError: Dispatch>;
+}
+
+/**
+ * Assembles the prompt-composer wiring shared by every prompt box: thread
+ * creation options, the draft store, `@`-mention + command typeahead, attachment
+ * upload, and the provider-owned prompt actions — pre-built into the
+ * execution/permission/typeahead/attachments configs the box needs. Surfaces
+ * differ on the box itself, the submit handler, and their chrome (project /
+ * branch pickers, queued-message stacks); those stay per-site.
+ */
+export function useComposerArea(
+ options: UseComposerAreaComponentLocalOptions,
+): UseComposerAreaResult;
+export function useComposerArea(
+ options: UseComposerAreaNewThreadOptions,
+): UseComposerAreaResult;
+export function useComposerArea(
+ options: UseComposerAreaComponentLocalOptions | UseComposerAreaNewThreadOptions,
+): UseComposerAreaResult {
+ const {
+ attachments,
+ commands,
+ creationOptions,
+ draftScope,
+ execution,
+ mentions,
+ mentionsProjectId,
+ permission,
+ resolveMentionLink,
+ } = options;
+
+ const threadCreationOptions = useThreadCreationOptions(
+ creationOptions as UseNewThreadCreationOptions,
+ );
+ const {
+ selectedProviderId,
+ setSelectedProviderId,
+ providerOptions,
+ hasMultipleProviders,
+ selectedProviderDisplayName,
+ selectedProviderComposerActions,
+ selectedModel,
+ setSelectedModel,
+ serviceTier,
+ setServiceTier,
+ reasoningLevel,
+ setReasoningLevel,
+ permissionMode,
+ setPermissionMode,
+ activeModel,
+ modelOptions,
+ moreModelOptions,
+ isLoadingModels,
+ modelLoadFailed,
+ modelLoadError,
+ reasoningOptions,
+ permissionModeOptions,
+ supportsPermissionModeSelection,
+ supportsServiceTier,
+ serviceTierSupportByProvider,
+ } = threadCreationOptions;
+
+ const promptDraft = usePromptDraftStorage(draftScope);
+ const resolvedMentions = useMemo(
+ () =>
+ typeof mentions === "function"
+ ? mentions(threadCreationOptions.environmentSelectionValue)
+ : mentions,
+ [mentions, threadCreationOptions.environmentSelectionValue],
+ );
+ const promptMentions = usePromptMentions(
+ mentionsProjectId,
+ resolvedMentions,
+ );
+ const uploadPromptAttachment = useUploadPromptAttachment();
+ const [commandQuery, setCommandQuery] = useState(null);
+ const [attachmentError, setAttachmentError] = useState(null);
+
+ const providerPromptActions = useMemo(
+ () => buildProviderPromptActionProps(selectedProviderComposerActions),
+ [selectedProviderComposerActions],
+ );
+ const promptActions = useMemo(
+ () => withLoopPromptAction(providerPromptActions.promptActions),
+ [providerPromptActions.promptActions],
+ );
+ const providerPromptActionProps = useMemo(
+ () => ({ promptActions }),
+ [promptActions],
+ );
+
+ const commandEnvironmentId =
+ typeof commands.environmentId === "function"
+ ? commands.environmentId(threadCreationOptions.environmentSelectionValue)
+ : commands.environmentId;
+ const commandSuggestions = useCommandSuggestions({
+ projectId: commands.projectId,
+ providerId: commands.providerId ?? selectedProviderId,
+ skillsTrigger: providerPromptActions.skillsTrigger,
+ environmentId: commandEnvironmentId,
+ query: commandQuery,
+ });
+
+ const currentPromptDraft = useMemo(
+ () => ({
+ text: promptDraft.text,
+ mentions: promptDraft.mentions,
+ attachments: promptDraft.attachments,
+ }),
+ [promptDraft.attachments, promptDraft.mentions, promptDraft.text],
+ );
+ const currentPromptDraftInput = useMemo(
+ () => promptDraftToInput(currentPromptDraft),
+ [currentPromptDraft],
+ );
+ const hasPromptDraftInput = currentPromptDraftInput.length > 0;
+
+ const attachmentProjectId = attachments.projectId;
+ const attachmentErrorMode = attachments.errorMode ?? "collect";
+ const handleAttachFiles = useCallback(
+ async (files: File[]) => {
+ if (files.length === 0) {
+ return;
+ }
+
+ setAttachmentError(null);
+ if (attachmentErrorMode === "first") {
+ for (const file of files) {
+ try {
+ const uploaded = await uploadPromptAttachment.mutateAsync({
+ projectId: attachmentProjectId,
+ file,
+ });
+ promptDraft.addAttachment(uploaded);
+ } catch (error) {
+ setAttachmentError(
+ getMutationErrorMessage({
+ error,
+ fallbackMessage: "Attachment upload failed",
+ }),
+ );
+ break;
+ }
+ }
+ return;
+ }
+
+ const failedFiles: string[] = [];
+ for (const file of files) {
+ try {
+ const uploaded = await uploadPromptAttachment.mutateAsync({
+ projectId: attachmentProjectId,
+ file,
+ });
+ promptDraft.addAttachment(uploaded);
+ } catch {
+ failedFiles.push(file.name);
+ }
+ }
+ if (failedFiles.length > 0) {
+ setAttachmentError(`Failed to attach: ${failedFiles.join(", ")}`);
+ }
+ },
+ [
+ attachmentErrorMode,
+ attachmentProjectId,
+ promptDraft,
+ uploadPromptAttachment,
+ ],
+ );
+
+ const attachmentsConfig = useMemo(
+ () => ({
+ items: promptDraft.attachments,
+ projectId: attachmentProjectId,
+ isAttaching: uploadPromptAttachment.isPending,
+ error: attachmentError,
+ onAttachFiles: handleAttachFiles,
+ onRemove: promptDraft.removeAttachment,
+ }),
+ [
+ attachmentError,
+ attachmentProjectId,
+ handleAttachFiles,
+ promptDraft.attachments,
+ promptDraft.removeAttachment,
+ uploadPromptAttachment.isPending,
+ ],
+ );
+
+ const typeaheadConfig = useMemo(
+ () => ({
+ mention: {
+ suggestions: promptMentions.suggestions,
+ isLoading: promptMentions.isLoading,
+ isError: promptMentions.isError,
+ onQueryChange: promptMentions.setQuery,
+ resolveLink: resolveMentionLink,
+ },
+ command: {
+ trigger: commandSuggestions.trigger,
+ suggestions: commandSuggestions.suggestions,
+ isLoading: commandSuggestions.isLoading,
+ isError: commandSuggestions.isError,
+ hasMore: commandSuggestions.hasMore,
+ isLoadingMore: commandSuggestions.isLoadingMore,
+ loadMore: commandSuggestions.loadMore,
+ onQueryChange: setCommandQuery,
+ },
+ }),
+ [
+ commandSuggestions.hasMore,
+ commandSuggestions.isError,
+ commandSuggestions.isLoading,
+ commandSuggestions.isLoadingMore,
+ commandSuggestions.loadMore,
+ commandSuggestions.suggestions,
+ commandSuggestions.trigger,
+ promptMentions.isError,
+ promptMentions.isLoading,
+ promptMentions.setQuery,
+ promptMentions.suggestions,
+ resolveMentionLink,
+ ],
+ );
+
+ const executionReadOnly = execution.readOnly ?? false;
+ const providerSwitchable = execution.providerSwitchable ?? false;
+ const executionConfig = useMemo(
+ () => ({
+ provider: {
+ options: providerOptions,
+ selectedId: selectedProviderId,
+ onChange:
+ !executionReadOnly && providerSwitchable
+ ? setSelectedProviderId
+ : undefined,
+ hasMultiple: hasMultipleProviders,
+ displayName: selectedProviderDisplayName,
+ },
+ model: {
+ active: activeModel,
+ selected: selectedModel,
+ options: modelOptions,
+ moreOptions: moreModelOptions,
+ isLoading: isLoadingModels,
+ loadFailed: modelLoadFailed,
+ loadError: modelLoadError,
+ onChange: executionReadOnly ? noop : setSelectedModel,
+ },
+ serviceTier: {
+ value: serviceTier,
+ onChange: executionReadOnly ? noop : setServiceTier,
+ supported: supportsServiceTier,
+ supportByProvider: serviceTierSupportByProvider,
+ },
+ reasoning: {
+ value: reasoningLevel,
+ options: reasoningOptions,
+ onChange: executionReadOnly ? noop : setReasoningLevel,
+ },
+ }),
+ [
+ activeModel,
+ executionReadOnly,
+ hasMultipleProviders,
+ isLoadingModels,
+ modelLoadError,
+ modelLoadFailed,
+ modelOptions,
+ moreModelOptions,
+ providerOptions,
+ providerSwitchable,
+ reasoningLevel,
+ reasoningOptions,
+ selectedModel,
+ selectedProviderDisplayName,
+ selectedProviderId,
+ serviceTier,
+ serviceTierSupportByProvider,
+ setReasoningLevel,
+ setSelectedModel,
+ setSelectedProviderId,
+ setServiceTier,
+ supportsServiceTier,
+ ],
+ );
+
+ const permissionKind = permission.kind;
+ const permissionPinnedValue =
+ permission.kind === "read-only" ? permission.value : undefined;
+ const permissionResolved =
+ permission.kind === "editable-gated" ? permission.resolved : false;
+ const permissionConfig = useMemo(() => {
+ if (permissionKind === "read-only") {
+ return {
+ value: permissionPinnedValue,
+ options: permissionModeOptions,
+ onChange: noop,
+ supported: supportsPermissionModeSelection,
+ };
+ }
+ if (permissionKind === "editable-gated") {
+ return {
+ value: permissionResolved ? permissionMode : undefined,
+ options: permissionResolved ? permissionModeOptions : [],
+ onChange: setPermissionMode,
+ supported: permissionResolved && supportsPermissionModeSelection,
+ };
+ }
+ return {
+ value: permissionMode,
+ options: permissionModeOptions,
+ onChange: setPermissionMode,
+ supported: supportsPermissionModeSelection,
+ };
+ }, [
+ permissionKind,
+ permissionMode,
+ permissionModeOptions,
+ permissionPinnedValue,
+ permissionResolved,
+ setPermissionMode,
+ supportsPermissionModeSelection,
+ ]);
+
+ const composer = useMemo(
+ () => ({
+ message: promptDraft.text,
+ mentionRanges: promptDraft.mentions,
+ onChangeMessage: promptDraft.setTextAndMentions,
+ currentPromptDraft,
+ currentPromptDraftInput,
+ hasPromptDraftInput,
+ }),
+ [
+ currentPromptDraft,
+ currentPromptDraftInput,
+ hasPromptDraftInput,
+ promptDraft.mentions,
+ promptDraft.setTextAndMentions,
+ promptDraft.text,
+ ],
+ );
+
+ return {
+ threadCreationOptions,
+ promptDraft,
+ promptActions,
+ providerPromptActionProps,
+ executionConfig,
+ permissionConfig,
+ typeaheadConfig,
+ attachmentsConfig,
+ composer,
+ attachmentError,
+ setAttachmentError,
+ };
+}
diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx
index 46ef6836f..7e4cabb51 100644
--- a/apps/app/src/components/sidebar/AppSidebar.tsx
+++ b/apps/app/src/components/sidebar/AppSidebar.tsx
@@ -35,6 +35,7 @@ import {
import {
getAutomationsRoutePath,
getRootComposeRoutePath,
+ getSkillsRoutePath,
getThreadRoutePath,
} from "@/lib/route-paths";
import { useRouteState } from "@/hooks/useRouteState";
@@ -77,7 +78,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);
@@ -169,6 +170,11 @@ export function AppSidebar({
void navigate(getAutomationsRoutePath());
}, [closeOnMobile, navigate]);
+ const handleOpenSkills = useCallback(() => {
+ closeOnMobile();
+ void navigate(getSkillsRoutePath());
+ }, [closeOnMobile, navigate]);
+
const handleThreadSearchKeyDown = useCallback<
KeyboardEventHandler
>(
@@ -298,6 +304,8 @@ export function AppSidebar({
>
void;
+ onOpenSkills?: () => void;
+ isSkillsActive?: boolean;
onOpenAutomations?: () => void;
isAutomationsActive?: boolean;
threadSearch?: SidebarThreadSearchInputController;
@@ -1187,6 +1189,8 @@ const SortableSidebarSection = memo(function SortableSidebarSection({
export function ProjectListActionButtons({
onNewChat,
+ onOpenSkills,
+ isSkillsActive = false,
onOpenAutomations,
isAutomationsActive = false,
threadSearch,
@@ -1270,6 +1274,22 @@ export function ProjectListActionButtons({
) : null}
)}
+ {onOpenSkills ? (
+
+ ) : null}
{onOpenAutomations ? (
) : null}
diff --git a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
index 4d7b7cb0e..410db5c2d 100644
--- a/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
+++ b/apps/app/src/components/thread/timeline/ThreadTimelineRows.tsx
@@ -99,7 +99,10 @@ import {
timelineRowsSignature,
} from "./timelineRowSignatures.js";
import { NESTED_TIMELINE_GROUP_LINE_CLASS_NAME } from "./timeline-nested-group-line.js";
-import { getThreadRoutePath } from "@/lib/route-paths";
+import {
+ getAutomationDetailRoutePath,
+ getThreadRoutePath,
+} from "@/lib/route-paths";
import { useThreadTimelineTurnSummaryDetails } from "@/hooks/queries/thread-queries";
import {
allThreadQueryKeyPrefix,
@@ -1399,6 +1402,8 @@ export function systemOperationLeadingIcon(
switch (operationKind) {
case "parent-change":
return parentChangeAction === "release" ? "UserRound" : "UserRoundPlus";
+ case "automation-created":
+ return "Repeat";
case "thread-provisioning":
return "Terminal";
case "thread-interrupted":
@@ -1790,6 +1795,14 @@ function ThreadTimelineRowsForTimelineView(props: ThreadTimelineRowsProps) {
});
const resolveSegmentLinkHref = useMemo(() => {
return (link) => {
+ // Loop links carry their own project + automation id, so they resolve
+ // regardless of the timeline's project context.
+ if (link.kind === "automation") {
+ return getAutomationDetailRoutePath({
+ projectId: link.projectId,
+ automationId: link.automationId,
+ });
+ }
// Thread routes are project-scoped; without a project context the
// segment renders as plain text.
return projectId !== undefined
diff --git a/apps/app/src/components/thread/timeline/rows/System.stories.tsx b/apps/app/src/components/thread/timeline/rows/System.stories.tsx
index f4e88a684..d667f0d40 100644
--- a/apps/app/src/components/thread/timeline/rows/System.stories.tsx
+++ b/apps/app/src/components/thread/timeline/rows/System.stories.tsx
@@ -296,6 +296,30 @@ const parentChangeTransfer: TimelineRow = systemRow({
},
});
+// system/operation with operation="automation_created". The projector routes
+// this to the automation-created row variant, which links the loop name to its
+// detail page. Emitted when an agent creates a loop from this thread.
+const automationCreated: TimelineRow = systemRow({
+ id: "thr_wxmxksux4w:op:automation-created:evt_loop",
+ threadId: "thr_wxmxksux4w",
+ turnId: null,
+ sourceSeqStart: 18,
+ sourceSeqEnd: 18,
+ startedAt: 1777890000000,
+ createdAt: 1777890000000,
+ systemKind: "operation",
+ operationKind: "automation-created",
+ title: "Created loop “Disk space watchdog”",
+ detail: null,
+ status: "completed",
+ completedAt: 1777890000000,
+ automationCreated: {
+ automationId: "auto_jujqt6vnjz",
+ projectId: "proj_personal",
+ automationName: "Disk space watchdog",
+ },
+});
+
// Non-operation system row, systemKind="debug". The projector emits these
// for raw provider events when debug routing is enabled — title is the
// rawType, detail is the JSON payload.
@@ -488,6 +512,17 @@ export function Operations() {
/>
+
+
+
+
+
);
}
diff --git a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
index cbfeda37a..912440e04 100644
--- a/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
+++ b/apps/app/src/components/thread/timeline/rows/UserMessage.stories.tsx
@@ -45,6 +45,8 @@ function resolveThreadLink(link: TimelineTitleLink): string | null {
switch (link.kind) {
case "thread":
return `/projects/proj_demo/threads/${link.threadId}`;
+ case "automation":
+ return `/automations/${link.projectId}/${link.automationId}`;
}
}
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 28f1d20bd..0c9886641 100644
--- a/apps/app/src/components/ui/theme.css
+++ b/apps/app/src/components/ui/theme.css
@@ -327,7 +327,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
@@ -534,7 +534,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 6774892c5..e749aa5d4 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
@@ -178,6 +178,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": [
"allAutomationDetailQueryKeyPrefix",
"allAutomationRunsQueryKeyPrefix",
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/automation-queries.ts b/apps/app/src/hooks/queries/automation-queries.ts
index 6d9962d5e..7c7b2c02b 100644
--- a/apps/app/src/hooks/queries/automation-queries.ts
+++ b/apps/app/src/hooks/queries/automation-queries.ts
@@ -4,6 +4,7 @@ import type {
AutomationRunListResponse,
AutomationRunResponse,
AutomationsOverviewResponse,
+ UpdateAutomationRequest,
} from "@bb/server-contract";
import * as api from "@/lib/api";
import { invalidateAutomationMutationQueries } from "@/hooks/cache-owners/automation-cache-effects";
@@ -141,3 +142,24 @@ export function useDeleteAutomation() {
},
});
}
+
+interface UpdateAutomationMutationRequest extends AutomationMutationRequest {
+ patch: UpdateAutomationRequest;
+}
+
+export function useUpdateAutomation() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ meta: { errorMessage: "Failed to update loop." },
+ mutationFn: (request: UpdateAutomationMutationRequest) =>
+ api.updateAutomation(request),
+ onSuccess: (_data, variables) => {
+ invalidateAutomationMutationQueries({
+ projectId: variables.projectId,
+ automationId: variables.automationId,
+ queryClient,
+ });
+ },
+ });
+}
diff --git a/apps/app/src/hooks/queries/query-keys.ts b/apps/app/src/hooks/queries/query-keys.ts
index bc0121fd4..827abe480 100644
--- a/apps/app/src/hooks/queries/query-keys.ts
+++ b/apps/app/src/hooks/queries/query-keys.ts
@@ -64,6 +64,8 @@ export const HOST_PATH_EXISTENCE_QUERY_KEY = "hostPathExistence";
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"];
@@ -1106,3 +1108,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/usePromptDraftStorage.test.tsx b/apps/app/src/hooks/usePromptDraftStorage.test.tsx
index ad93cc5fd..ffb1fffd1 100644
--- a/apps/app/src/hooks/usePromptDraftStorage.test.tsx
+++ b/apps/app/src/hooks/usePromptDraftStorage.test.tsx
@@ -75,6 +75,19 @@ describe("usePromptDraftStorage", () => {
"bb.promptbox.contents-proj_prompt-thr_followup-3",
);
});
+
+ it("keeps automation edit drafts scoped to the automation", () => {
+ const { result } = renderHook(() =>
+ usePromptDraftStorage({
+ kind: "automation-edit",
+ automationId: "auto_watchdog",
+ }),
+ );
+
+ expect(result.current.storageKey).toBe(
+ "bb.promptbox.contents-automation-edit-auto_watchdog-3",
+ );
+ });
});
describe("usePromptDraftStorage addQuote", () => {
diff --git a/apps/app/src/hooks/usePromptDraftStorage.ts b/apps/app/src/hooks/usePromptDraftStorage.ts
index 274675e52..8ebe13d22 100644
--- a/apps/app/src/hooks/usePromptDraftStorage.ts
+++ b/apps/app/src/hooks/usePromptDraftStorage.ts
@@ -18,6 +18,7 @@ const PROMPT_DRAFT_STORAGE_VERSION = "3";
const PROMPT_DRAFT_PERSIST_DEBOUNCE_MS = 250;
export type PromptDraftScope =
+ | { kind: "automation-edit"; automationId: string }
| { kind: "new-thread" }
| { kind: "side-chat"; parentThreadId: string; tabId: string }
| { kind: "thread"; projectId: string; threadId: string };
@@ -222,6 +223,10 @@ function restorePromptDraftIfEmpty(
}
function getPromptDraftStorageKey(scope: PromptDraftScope): string | null {
+ if (scope.kind === "automation-edit") {
+ const normalizedAutomationId = normalizeStorageSegment(scope.automationId);
+ return `${PROMPT_DRAFT_STORAGE_PREFIX}-automation-edit-${normalizedAutomationId}-${PROMPT_DRAFT_STORAGE_VERSION}`;
+ }
if (scope.kind === "new-thread") {
return `${PROMPT_DRAFT_STORAGE_PREFIX}-draft-${PROMPT_DRAFT_STORAGE_VERSION}`;
}
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/hooks/useThreadCreationOptions.ts b/apps/app/src/hooks/useThreadCreationOptions.ts
index 0cbc8a94b..47052bf40 100644
--- a/apps/app/src/hooks/useThreadCreationOptions.ts
+++ b/apps/app/src/hooks/useThreadCreationOptions.ts
@@ -87,7 +87,7 @@ type ReasoningLevelSelectionSetter = (value: ReasoningLevel) => void;
type PermissionModeSelectionSetter = (value: PermissionMode) => void;
type ClearSelectionHandler = () => void;
-interface UseThreadCreationOptionsResult {
+export interface UseThreadCreationOptionsResult {
selectedProviderId: string;
setSelectedProviderId: StringSelectionSetter;
providerOptions: PickerOption[];
diff --git a/apps/app/src/lib/api.ts b/apps/app/src/lib/api.ts
index fb1be6e31..184937cc9 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,
@@ -687,6 +693,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/format-schedule.ts b/apps/app/src/lib/format-schedule.ts
index b4a89b443..a1778f93f 100644
--- a/apps/app/src/lib/format-schedule.ts
+++ b/apps/app/src/lib/format-schedule.ts
@@ -21,17 +21,57 @@ export interface CompletedOneShotAutomationArgs {
runCount: number;
}
+const DAY_ABBREVIATION: Record = {
+ Sunday: "Sun",
+ Monday: "Mon",
+ Tuesday: "Tue",
+ Wednesday: "Wed",
+ Thursday: "Thu",
+ Friday: "Fri",
+ Saturday: "Sat",
+};
+
/**
- * Human-readable recurrence for a cron expression, e.g.
- * "At 09:00 AM, Monday through Friday". Falls back to a neutral label rather
- * than surfacing the raw cron string when the expression can't be parsed.
+ * Compact human-readable recurrence for a cron expression, tuned for dense
+ * lists: "9AM Mon-Fri", "5PM Fri", "Every 15 min". Post-processes cronstrue's
+ * verbose phrasing. Falls back to a neutral label when the expression can't be
+ * parsed.
*/
export function formatCronCadence(cron: string): string {
+ let text: string;
try {
- return cronstrueToString(cron, { verbose: false });
+ text = cronstrueToString(cron, { verbose: false });
} catch {
return "Custom schedule";
}
+ return (
+ text
+ // Drop cronstrue's leading "At " ("At 09:00 AM, …").
+ .replace(/^At /, "")
+ // Compact clock times: "09:00 AM" -> "9AM", "09:30 PM" -> "9:30PM".
+ .replace(
+ /\b0?(\d{1,2}):(\d{2})\s*(AM|PM)\b/g,
+ (_all, hour, minute, meridiem) =>
+ minute === "00"
+ ? `${hour}${meridiem}`
+ : `${hour}:${minute}${meridiem}`,
+ )
+ // Abbreviate day names to three letters.
+ .replace(
+ /\b(Sunday|Monday|Tuesday|Wednesday|Thursday|Friday|Saturday)\b/g,
+ (day) => DAY_ABBREVIATION[day] ?? day,
+ )
+ // Ranges, list joins, and filler.
+ .replace(/ through /g, "-")
+ .replace(/,? only on /g, " ")
+ .replace(/,? and /g, ", ")
+ // Units.
+ .replace(/\bminutes?\b/g, "min")
+ .replace(/\bseconds?\b/g, "sec")
+ // Collapse the comma between the time and the day spec into a space.
+ .replace(/([AP]M),\s+/g, "$1 ")
+ .trim()
+ );
}
export function formatAutomationTrigger(trigger: AutomationTrigger): string {
diff --git a/apps/app/src/lib/route-paths.ts b/apps/app/src/lib/route-paths.ts
index 823159587..a24f4495b 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/test/fixtures/thread-timeline-rows.ts b/apps/app/src/test/fixtures/thread-timeline-rows.ts
index d88494455..5e2b4f5a4 100644
--- a/apps/app/src/test/fixtures/thread-timeline-rows.ts
+++ b/apps/app/src/test/fixtures/thread-timeline-rows.ts
@@ -2,6 +2,7 @@ import type {
TimelineActivityIntent,
TimelineApprovalStatus,
TimelineApprovalWorkRow,
+ TimelineAutomationCreated,
TimelineCommandWorkRow,
TimelineConversationAttachments,
TimelineConversationRow,
@@ -215,6 +216,7 @@ export interface SystemRowArgs extends RowBaseOverrideArgs {
durationMs?: number | null;
id?: string;
parentChange?: TimelineParentChange;
+ automationCreated?: TimelineAutomationCreated;
operationKind?: TimelineSystemOperationKind;
seq?: number;
sourceSeqEnd?: number;
@@ -956,6 +958,7 @@ export function systemRow({
durationMs,
id = DEFAULT_SYSTEM_ID,
parentChange,
+ automationCreated,
operationKind,
seq,
sourceSeqEnd,
@@ -990,7 +993,12 @@ export function systemRow({
};
}
const resolvedOperationKind =
- operationKind ?? (parentChange ? "parent-change" : "generic");
+ operationKind ??
+ (parentChange
+ ? "parent-change"
+ : automationCreated
+ ? "automation-created"
+ : "generic");
const resolvedCompletedAt =
completedAt !== undefined
? completedAt
@@ -1020,6 +1028,19 @@ export function systemRow({
},
};
}
+ if (resolvedOperationKind === "automation-created") {
+ return {
+ ...base,
+ systemKind,
+ operationKind: resolvedOperationKind,
+ completedAt: resolvedCompletedAt,
+ automationCreated: automationCreated ?? {
+ automationId: "auto_fixture",
+ projectId: "proj_fixture",
+ automationName: "Fixture loop",
+ },
+ };
+ }
return {
...base,
systemKind,
diff --git a/apps/app/src/views/AutomationDetailPane.tsx b/apps/app/src/views/AutomationDetailPane.tsx
new file mode 100644
index 000000000..8a9554af0
--- /dev/null
+++ b/apps/app/src/views/AutomationDetailPane.tsx
@@ -0,0 +1,137 @@
+import { useCallback } from "react";
+import type { Automation, UpdateAutomationRequest } from "@bb/server-contract";
+import {
+ ConfirmDeleteDialog,
+ ConfirmDeleteDialogContent,
+} from "@/components/dialogs/ConfirmDeleteDialog.js";
+import { Button } from "@/components/ui/button.js";
+import { Icon } from "@/components/ui/icon.js";
+import {
+ useAutomationDetail,
+ useAutomationRuns,
+ useDeleteAutomation,
+ usePauseAutomation,
+ useResumeAutomation,
+ useUpdateAutomation,
+} from "@/hooks/queries/automation-queries";
+import { useDialogState } from "@/hooks/useDialogState";
+import { AutomationDetailContent } from "./AutomationDetailView.js";
+
+interface AutomationDetailPaneProps {
+ /**
+ * The overview's copy of the selected loop; renders instantly while the detail
+ * query refreshes, and supplies the ids the pane fetches by.
+ */
+ automation: Automation;
+ /** Bumped per open so the detail content remounts fresh when switching loops. */
+ sessionKey?: number;
+ /** Edit lives on the roomy full page; the pane's Edit action navigates there. */
+ onEdit: () => void;
+ onClose: () => void;
+}
+
+/**
+ * Docked right-side detail pane — a read-only glance at a loop. Selecting a row
+ * opens this inline beside the list (no scrim, list stays interactive). Editing
+ * is a deliberate, roomier task, so the pane's Edit action navigates to the
+ * full-page `/automations/:id` route rather than cramming a form in here. The
+ * full page (also the deep-link target) reuses the same `AutomationDetailContent`.
+ */
+export function AutomationDetailPane({
+ automation,
+ sessionKey,
+ onEdit,
+ onClose,
+}: AutomationDetailPaneProps) {
+ const projectId = automation.projectId;
+ const automationId = automation.id;
+
+ const detailQuery = useAutomationDetail(projectId, automationId);
+ const runsQuery = useAutomationRuns(projectId, automationId);
+ const pauseAutomation = usePauseAutomation();
+ const resumeAutomation = useResumeAutomation();
+ const deleteAutomation = useDeleteAutomation();
+ const updateAutomation = useUpdateAutomation();
+ const deleteDialog = useDialogState();
+
+ const { mutate: pauseMutate } = pauseAutomation;
+ const { mutate: resumeMutate } = resumeAutomation;
+ const { mutate: deleteMutate } = deleteAutomation;
+ const { mutateAsync: updateMutateAsync } = updateAutomation;
+ const { onClose: closeDeleteDialog, onOpen: openDeleteDialog } = deleteDialog;
+
+ const handleSave = useCallback(
+ async (patch: UpdateAutomationRequest) => {
+ await updateMutateAsync({ projectId, automationId, patch });
+ },
+ [updateMutateAsync, projectId, automationId],
+ );
+ const confirmDelete = useCallback(() => {
+ deleteMutate(
+ { projectId, automationId },
+ {
+ onSuccess: () => {
+ closeDeleteDialog();
+ onClose();
+ },
+ },
+ );
+ }, [deleteMutate, projectId, automationId, closeDeleteDialog, onClose]);
+
+ // Show the overview's automation immediately; refine with the detail fetch.
+ const current = detailQuery.data ?? automation;
+ const runs = runsQuery.data?.runs ?? [];
+ const hasRunsError = runsQuery.isError && runsQuery.data === undefined;
+ const isRunsLoading =
+ runsQuery.isFetching && runsQuery.data === undefined && !hasRunsError;
+ const actionsPending =
+ pauseAutomation.isPending ||
+ resumeAutomation.isPending ||
+ deleteAutomation.isPending;
+
+ return (
+
+ );
+}
diff --git a/apps/app/src/views/AutomationDetailView.agent-edit.test.tsx b/apps/app/src/views/AutomationDetailView.agent-edit.test.tsx
new file mode 100644
index 000000000..4b438ea63
--- /dev/null
+++ b/apps/app/src/views/AutomationDetailView.agent-edit.test.tsx
@@ -0,0 +1,379 @@
+// @vitest-environment jsdom
+
+import {
+ cleanup,
+ fireEvent,
+ render,
+ screen,
+ waitFor,
+} from "@testing-library/react";
+import { MemoryRouter } from "react-router-dom";
+import type { ReactNode } from "react";
+import {
+ PERSONAL_PROJECT_ID,
+ type PermissionMode,
+ type ReasoningLevel,
+} from "@bb/domain";
+import type {
+ Automation,
+ SystemExecutionOptionsResponse,
+ UpdateAutomationRequest,
+} from "@bb/server-contract";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import * as api from "@/lib/api";
+import { createQueryClientTestHarness } from "@/test/queryClientTestHarness";
+import { AutomationDetailContent } from "./AutomationDetailView";
+
+vi.mock("@/components/promptbox/PromptBoxInternal", () => ({
+ PromptBoxInternal: ({
+ attachments,
+ footerStart,
+ onChange,
+ onSubmit,
+ value,
+ }: {
+ attachments: {
+ onAttachFiles?: (files: File[]) => void | Promise;
+ };
+ footerStart: ReactNode;
+ onChange: (value: string, mentions: []) => void;
+ onSubmit: () => void;
+ value: string;
+ }) => (
+
) : runs.length === 0 ? (
- No runs yet.
+
+ No runs yet.{" "}
+ {automation.enabled
+ ? automation.nextRunAt !== null
+ ? `Runs appear here after the next trigger, ${formatRunTimestamp(automation.nextRunAt)}.`
+ : "Runs appear here once the loop is scheduled."
+ : "Resume the loop to schedule its first run."}
+
) : (
-
+ ) : hasInitialLoadError ? (
+ // Failure is direction, not a dead end: say what happened plainly and
+ // offer the way out, kept calm rather than alarmist.
+
+
);
}
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..8fd3c3537
--- /dev/null
+++ b/apps/app/src/views/SkillsView.tsx
@@ -0,0 +1,686 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import type { CSSProperties } 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",
+];
+const SKILL_ROW_HEIGHT_REM = 1.75;
+const SKILL_VISIBLE_ROW_COUNT = 15;
+const SKILL_ROW_GAP_PX = 1;
+const SKILL_LIST_VERTICAL_PADDING_REM = 0.5;
+const SKILL_LIST_MAX_HEIGHT = `calc(${
+ SKILL_ROW_HEIGHT_REM * SKILL_VISIBLE_ROW_COUNT +
+ SKILL_LIST_VERTICAL_PADDING_REM
+}rem + ${SKILL_ROW_GAP_PX * (SKILL_VISIBLE_ROW_COUNT - 1)}px)`;
+const skillListViewportStyle = {
+ maxHeight: SKILL_LIST_MAX_HEIGHT,
+} satisfies CSSProperties;
+
+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.
+