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; + }) => ( +
+