diff --git a/apps/app/src/components/dialogs/ThreadGitActionDialog.tsx b/apps/app/src/components/dialogs/ThreadGitActionDialog.tsx index cbe2414fd8a..1fe1ef061c9 100644 --- a/apps/app/src/components/dialogs/ThreadGitActionDialog.tsx +++ b/apps/app/src/components/dialogs/ThreadGitActionDialog.tsx @@ -1,9 +1,5 @@ import type { FormEvent } from "react"; -import { - DetailCard, - DetailRow, - DetailRowIconLabel, -} from "@/components/ui/detail-card.js"; +import { DetailCard, DetailRow } from "@/components/ui/detail-card.js"; import { SquashMergeInfo, type ThreadGitStatusDisplay, @@ -91,28 +87,14 @@ export function ThreadGitActionDialogContent({ {branchName || gitStatusDisplay || shouldShowChangedFilesRow ? ( {branchName ? ( - - Branch - - } - valueClassName="min-w-0 truncate" - > + {branchName} ) : null} {gitStatusDisplay ? ( - - Git status - - } - valueClassName="min-w-0" - > +
Parent} - valueClassName="min-w-0" - > + {parentThreadId ? (
Notify parent - } - valueClassName="min-w-0" - > + Environment ) : ( - - Environment - + "Environment" ) } - valueClassName="min-w-0" + icon={ + providerLookup.status === "loaded" && providerLookup.provider !== null + ? undefined + : infoDisplay.icon + } > @@ -364,7 +369,6 @@ export function EnvironmentRow({ Machine } - valueClassName="min-w-0" > Project} - valueClassName="min-w-0" - > + {projectName} @@ -411,14 +412,7 @@ export function EnvironmentProvisioningFailureRow({ }) { if (!failed) return null; return ( - - Environment - - } - valueClassName="min-w-0" - > + Not created @@ -457,12 +451,7 @@ function WorkspacePathValue({ path }: { path: string }) { ); return ( - Directory - } - valueClassName="min-w-0" - > +
- {checkoutDisplay.rowLabel} - - } - valueClassName="min-w-0 truncate" - > + {checkoutDisplay.copyValue !== null ? ( - - {checkoutDisplay.label} - + + + {checkoutDisplay.label} + + ) : ( {checkoutDisplay.label} @@ -527,14 +513,7 @@ export function PullRequestRow({ pullRequest }: PullRequestRowProps) { const stateDisplay = getPullRequestStateDisplay(pullRequest); const nextStep = getPullRequestNextStep(pullRequest); return ( - - Pull request - - } - valueClassName="min-w-0" - > + Merge base - } - valueClassName="min-w-0" - > + {canSelectMergeBase && mergeBaseBranch ? ( Git status - } - valueClassName="min-w-0" - > + {formatBranchComparison({ @@ -799,13 +768,7 @@ export function GitStatusRow({ workspaceDeleted: environment?.status === "destroyed", }); return ( - Git status - } - align="start" - valueClassName="min-w-0" - > + Archived} - valueClassName="min-w-0 truncate" - > - + + + + ); } @@ -970,124 +935,282 @@ export function ThreadMetadataCard({ children }: DetailCardWrapperProps) { ); } -export function ThreadMetadataContent(props: ThreadMetadataContentProps) { - const { - thread, - projectId, - parentThreadProjectId, - parentThreadDisplayName, - parentThreads, - canAssignToParent, - canTakeOverThread, - isLoadingParentThreads, - isParentThreadsError, - environment, - environmentProvisioningFailure, - environmentDisplayHost, - workspaceStatus, - workspaceStatusError, - workspaceUnavailable, - pullRequest, - selectedMergeBaseBranch, - mergeBaseBranchRef, - mergeBaseBranchOptions, - mergeBaseRemoteBranchOptions, - isLoadingMergeBaseBranchOptions, - updateThreadPending, - storage, - onAssignParent, - onParentSelectorOpenChange, - onRetryParentThreads, - onMergeBaseBranchChange, - onMergeBasePickerOpenChange, - onMergeBaseBranchSearchQueryChange, - onChangedFileClick, - onCommitClick, - onOpenChangedFile, - } = props; - - const isHostActive = - environment === null || environment.hostLifecycle === "active"; +const ThreadInfoHostContext = createContext( + null, +); + +function useThreadInfoHost(): ThreadMetadataContentProps { + const host = useContext(ThreadInfoHostContext); + if (host === null) { + throw new Error( + "thread Info rows must render inside ThreadMetadataContent", + ); + } + return host; +} +function useIsThreadInfoHostActive(): boolean { + const { environment } = useThreadInfoHost(); + return environment === null || environment.hostLifecycle === "active"; +} + +function CoreParentRow() { + const host = useThreadInfoHost(); return ( - -
-
- - - - - + ); +} + +function CoreParentNotificationsRow() { + return ; +} + +function CoreProjectRow({ projectId }: ExperimentalThreadInfoProps) { + return ; +} + +function CoreEnvironmentRow() { + const host = useThreadInfoHost(); + return ( + + ); +} + +function CoreEnvironmentProvisioningFailureRow() { + return ( + + ); +} + +function CoreWorkspacePathRow() { + return ; +} + +function CoreBranchRow() { + const host = useThreadInfoHost(); + if (!useIsThreadInfoHostActive()) return null; + return ; +} + +function CoreMergeBaseRow() { + const host = useThreadInfoHost(); + if (!useIsThreadInfoHostActive()) return null; + return ( + + ); +} + +function CoreGitStatusRow() { + const host = useThreadInfoHost(); + if (!useIsThreadInfoHostActive()) return null; + return ( + + ); +} + +function CorePullRequestRow() { + return ; +} + +function CoreArchivedRow() { + return ; +} + +function CoreChildThreadsSection() { + return ; +} + +function CoreForksSection() { + return ; +} + +function CoreCommitsSection() { + const host = useThreadInfoHost(); + if (!useIsThreadInfoHostActive()) return null; + return ( + + ); +} + +function CoreUncommittedChangesSection() { + const host = useThreadInfoHost(); + if (!useIsThreadInfoHostActive()) return null; + return ( + + ); +} + +function CoreThreadStorageSection() { + const { storage } = useThreadInfoHost(); + return storage ? : null; +} + +const ROW_GROUPS = experimental_THREAD_INFO_ROW_GROUPS; +const SECTION_GROUPS = experimental_THREAD_INFO_SECTION_GROUPS; + +const CORE_THREAD_INFO_ROWS: readonly CoreThreadInfoRegistration[] = [ + { + id: "parent", + group: ROW_GROUPS.thread, + order: 10, + component: CoreParentRow, + }, + { + id: "parentNotifications", + group: ROW_GROUPS.thread, + order: 20, + component: CoreParentNotificationsRow, + }, + { + id: "project", + group: ROW_GROUPS.thread, + order: 30, + component: CoreProjectRow, + }, + { + id: "environment", + group: ROW_GROUPS.environment, + order: 10, + component: CoreEnvironmentRow, + }, + { + id: "environmentProvisioningFailure", + group: ROW_GROUPS.environment, + order: 20, + component: CoreEnvironmentProvisioningFailureRow, + }, + { + id: "directory", + group: ROW_GROUPS.environment, + order: 30, + component: CoreWorkspacePathRow, + }, + { id: "branch", group: ROW_GROUPS.git, order: 10, component: CoreBranchRow }, + { + id: "mergeBase", + group: ROW_GROUPS.git, + order: 20, + component: CoreMergeBaseRow, + }, + { + id: "gitStatus", + group: ROW_GROUPS.git, + order: 30, + component: CoreGitStatusRow, + }, + { + id: "pullRequest", + group: ROW_GROUPS.pullRequest, + order: 10, + component: CorePullRequestRow, + }, + { + id: "archived", + group: ROW_GROUPS.lifecycle, + order: 10, + component: CoreArchivedRow, + }, +]; + +const CORE_THREAD_INFO_SECTIONS: readonly CoreThreadInfoRegistration[] = [ + { + id: "childThreads", + group: SECTION_GROUPS.related, + order: 10, + component: CoreChildThreadsSection, + }, + { + id: "forks", + group: SECTION_GROUPS.related, + order: 20, + component: CoreForksSection, + }, + { + id: "commits", + group: SECTION_GROUPS.changes, + order: 10, + component: CoreCommitsSection, + }, + { + id: "uncommittedChanges", + group: SECTION_GROUPS.changes, + order: 20, + component: CoreUncommittedChangesSection, + }, + { + id: "threadStorage", + group: SECTION_GROUPS.storage, + order: 10, + component: CoreThreadStorageSection, + }, +]; + +export function ThreadMetadataContent(props: ThreadMetadataContentProps) { + return ( + + +
+
+ +
+ - - {isHostActive ? ( - <> - - - - - ) : null} - -
- - - {isHostActive ? ( - <> - - - - ) : null} - {storage ? : null} -
- + + ); } diff --git a/apps/app/src/components/secondary-panel/info/ChangesSection.tsx b/apps/app/src/components/secondary-panel/info/ChangesSection.tsx index 5658a02caf8..c1c178b942a 100644 --- a/apps/app/src/components/secondary-panel/info/ChangesSection.tsx +++ b/apps/app/src/components/secondary-panel/info/ChangesSection.tsx @@ -16,7 +16,6 @@ import { import { getFileNameFromPath } from "../rightPanelFileVisuals"; import { FILE_STATUS_GLYPHS } from "./file-status-glyphs"; import { InfoList, InfoListRow, InfoSection } from "./info-list"; -import { useInfoSectionCollapse } from "./useInfoSectionCollapse"; function toFolderHint(path: string): string | null { const folderPath = directoryFromPath(path); @@ -90,14 +89,12 @@ export function UncommittedChangesSection({ onChangedFileClick, onOpenChangedFile, }: ChangedFilesHandlers & { workspaceStatus: WorkspaceStatus | undefined }) { - const collapse = useInfoSectionCollapse("uncommittedChanges"); const section = selectChangedFilesSection(workspaceStatus, "uncommitted"); if (section === undefined) return null; return ( } > + + relatedThread.id} @@ -73,7 +69,6 @@ export function ChildThreadsSection({ thread }: { thread: Thread }) { }); return ( childThread.originKind === null, @@ -91,10 +86,6 @@ export function ForksSection({ thread }: { thread: Thread }) { archived: false, }); return ( - + ); } diff --git a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx new file mode 100644 index 00000000000..aa6a96df465 --- /dev/null +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { Provider as JotaiProvider, createStore } from "jotai"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ExperimentalThreadInfoProps } from "@get-bb/plugin-sdk"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { DetailRow } from "@/components/ui/detail-card"; +import { + removePluginSlotRegistrations, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { makePluginRegistrationSet } from "@/test/fixtures/plugins"; +import { InfoSection } from "./info-list"; +import { + ThreadInfoRows, + ThreadInfoSections, + type CoreThreadInfoRegistration, +} from "./ThreadInfoSlots"; + +function row(label: string) { + return function Row({ threadId }: ExperimentalThreadInfoProps) { + return ( + + {label} for {threadId} + + ); + }; +} + +function section(label: string) { + return function Section() { + return ( + +

{label} body

+
+ ); + }; +} + +function Crashes(): never { + throw new Error("plugin crashed"); +} + +const CORE_ROWS: CoreThreadInfoRegistration[] = [ + { id: "first", group: "1_a", order: 10, component: row("Core first") }, + { id: "second", group: "1_a", order: 20, component: row("Core second") }, + { id: "later", group: "3_c", order: 10, component: row("Core later") }, +]; + +const CORE_SECTIONS: CoreThreadInfoRegistration[] = [ + { + id: "coreSection", + group: "1_a", + order: 10, + component: section("Core section"), + }, +]; + +function renderInfo() { + return render( + + +
+ + +
+
+
, + ); +} + +afterEach(() => { + cleanup(); + removePluginSlotRegistrations("demo"); + resetAllCrashedPluginSlotsForTest(); + vi.restoreAllMocks(); +}); + +describe("thread Info slots", () => { + it("places plugin rows and sections by group, then order, after core on a tie", () => { + setPluginSlotRegistrations( + "demo", + makePluginRegistrationSet({ + threadInfoRows: [ + { id: "own", group: "2_b", component: row("Plugin own group") }, + { id: "unordered", group: "1_a", component: row("Plugin unordered") }, + { id: "tie", group: "1_a", order: 20, component: row("Plugin tie") }, + { + id: "between", + group: "1_a", + order: 15, + component: row("Plugin between"), + }, + ], + threadInfoSections: [ + { id: "late", group: "9_z", component: section("Plugin late") }, + { + id: "early", + group: "1_a", + order: 0, + component: section("Plugin early"), + }, + ], + }), + ); + renderInfo(); + + expect(screen.getAllByRole("term").map((term) => term.textContent)).toEqual( + [ + "Core first", + "Plugin between", + "Core second", + "Plugin tie", + "Plugin unordered", + "Plugin own group", + "Core later", + ], + ); + expect( + screen.getAllByRole("heading").map((heading) => heading.textContent), + ).toEqual(["Plugin early1", "Core section1", "Plugin late1"]); + expect(screen.getByText("Plugin between for thr_1")).toBeTruthy(); + }); + + it("drops only a crashing plugin row or section", () => { + vi.spyOn(console, "error").mockImplementation(() => {}); + setPluginSlotRegistrations( + "demo", + makePluginRegistrationSet({ + threadInfoRows: [{ id: "broken", group: "1_a", component: Crashes }], + threadInfoSections: [ + { id: "broken", group: "1_a", component: Crashes }, + ], + }), + ); + renderInfo(); + + expect(screen.getAllByRole("term").map((term) => term.textContent)).toEqual( + ["Core first", "Core second", "Core later"], + ); + expect(screen.getByText("Core section body")).toBeTruthy(); + expect(screen.queryByText(/demo/)).toBeNull(); + }); + + it("collapses each section on its own, keyed by registration", () => { + setPluginSlotRegistrations( + "demo", + makePluginRegistrationSet({ + threadInfoSections: [ + { + id: "coreSection", + group: "1_a", + order: 20, + component: section("Plugin"), + }, + ], + }), + ); + renderInfo(); + + fireEvent.click(screen.getByRole("button", { name: /^Plugin/ })); + + expect(screen.queryByText("Plugin body")).toBeNull(); + expect(screen.getByText("Core section body")).toBeTruthy(); + expect( + screen + .getByRole("button", { name: /^Core section/ }) + .getAttribute("aria-expanded"), + ).toBe("true"); + }); +}); diff --git a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx new file mode 100644 index 00000000000..d49a848778f --- /dev/null +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx @@ -0,0 +1,111 @@ +import type { ComponentType } from "react"; +import type { ExperimentalThreadInfoProps } from "@get-bb/plugin-sdk"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { compareGroupedPlacement } from "@/lib/grouped-placement"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { InfoSectionCollapseIdContext } from "./useInfoSectionCollapse"; + +export interface CoreThreadInfoRegistration { + id: string; + group: string; + order: number; + component: ComponentType; +} + +interface PluginThreadInfoRegistration { + id: string; + group: string; + order?: number; + component: ComponentType; + pluginId: string; + generation: number; +} + +type ThreadInfoEntry = + | { owner: "core"; registration: CoreThreadInfoRegistration } + | { owner: "plugin"; registration: PluginThreadInfoRegistration }; + +function sortThreadInfoEntries( + core: readonly CoreThreadInfoRegistration[], + plugin: readonly PluginThreadInfoRegistration[], +): ThreadInfoEntry[] { + const entries: ThreadInfoEntry[] = [ + ...core.map((registration) => ({ owner: "core" as const, registration })), + ...plugin.map((registration) => ({ + owner: "plugin" as const, + registration, + })), + ]; + return entries + .map((entry, index) => ({ entry, index })) + .sort((left, right) => + compareGroupedPlacement( + left.entry.registration, + left.index, + right.entry.registration, + right.index, + ), + ) + .map(({ entry }) => entry); +} + +interface ThreadInfoSlotsProps extends ExperimentalThreadInfoProps { + core: readonly CoreThreadInfoRegistration[]; +} + +export function ThreadInfoRows({ core, ...props }: ThreadInfoSlotsProps) { + const { threadInfoRows } = usePluginSlots(); + return sortThreadInfoEntries(core, threadInfoRows).map((entry) => { + const Component = entry.registration.component; + if (entry.owner === "core") { + return ; + } + const { pluginId, id, generation } = entry.registration; + return ( + + + + ); + }); +} + +export function ThreadInfoSections({ core, ...props }: ThreadInfoSlotsProps) { + const { threadInfoSections } = usePluginSlots(); + return sortThreadInfoEntries(core, threadInfoSections).map((entry) => { + const Component = entry.registration.component; + if (entry.owner === "core") { + return ( + + + + ); + } + const { pluginId, id, generation } = entry.registration; + return ( + + + + + + ); + }); +} diff --git a/apps/app/src/components/secondary-panel/info/ThreadStorageSection.tsx b/apps/app/src/components/secondary-panel/info/ThreadStorageSection.tsx index d4533ff325f..706bf01494c 100644 --- a/apps/app/src/components/secondary-panel/info/ThreadStorageSection.tsx +++ b/apps/app/src/components/secondary-panel/info/ThreadStorageSection.tsx @@ -25,7 +25,6 @@ import { type ThreadStorageFlattenOptions, type ThreadStorageTreeRow, } from "./thread-storage-tree"; -import { useInfoSectionCollapse } from "./useInfoSectionCollapse"; const THREAD_STORAGE_FOLDER_CHILD_LIMIT = 5; @@ -82,7 +81,6 @@ export function ThreadStorageSection({ toggleFolder, } = controller; const [isSearchOpen, setIsSearchOpen] = useState(false); - const collapse = useInfoSectionCollapse("threadStorage"); const tree = useMemo( () => buildThreadStorageTree(filteredFiles), [filteredFiles], @@ -191,7 +189,6 @@ export function ThreadStorageSection({ diff --git a/apps/app/src/components/secondary-panel/info/info-list.tsx b/apps/app/src/components/secondary-panel/info/info-list.tsx index bf140751df8..1471dba0247 100644 --- a/apps/app/src/components/secondary-panel/info/info-list.tsx +++ b/apps/app/src/components/secondary-panel/info/info-list.tsx @@ -12,6 +12,7 @@ import { cn } from "@bb/shared-ui/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { TruncateStart } from "@bb/shared-ui/truncate-start"; import { formatCompactRelativeTime } from "@/lib/relative-time"; +import { useScopedInfoSectionCollapse } from "./useInfoSectionCollapse"; const INFO_LIST_LEADING_CLASS = "flex size-3 shrink-0 items-center justify-center"; @@ -125,12 +126,22 @@ export interface InfoSectionProps extends InfoSectionHeadingProps { children: ReactNode; } -export function InfoSection({ children, ...heading }: InfoSectionProps) { +export function InfoSection({ + children, + collapse, + ...heading +}: InfoSectionProps) { const bodyId = useId(); - const collapsed = heading.collapse?.collapsed ?? false; + const scopedCollapse = useScopedInfoSectionCollapse(); + const resolvedCollapse = collapse ?? scopedCollapse; + const collapsed = resolvedCollapse?.collapsed ?? false; return ( -
- +
+ {collapsed ? null :
{children}
}
); @@ -316,7 +327,10 @@ function InfoRowTouchActionsMenu({ diff --git a/apps/app/src/components/secondary-panel/info/useInfoSectionCollapse.ts b/apps/app/src/components/secondary-panel/info/useInfoSectionCollapse.ts index a55b087cde8..d24373ccbf0 100644 --- a/apps/app/src/components/secondary-panel/info/useInfoSectionCollapse.ts +++ b/apps/app/src/components/secondary-panel/info/useInfoSectionCollapse.ts @@ -1,4 +1,4 @@ -import { useCallback } from "react"; +import { createContext, useCallback, useContext } from "react"; import { useAtom } from "jotai"; import { createSyncedPreferenceAtom } from "@/lib/ui-preferences/synced-preference-atom"; import type { InfoSectionCollapse } from "./info-list"; @@ -7,11 +7,16 @@ const collapsedInfoSectionIdsAtom = createSyncedPreferenceAtom( "infoPanel.collapsedSections", ); -export function useInfoSectionCollapse(sectionId: string): InfoSectionCollapse { +export const InfoSectionCollapseIdContext = createContext(null); + +export function useScopedInfoSectionCollapse(): + | InfoSectionCollapse + | undefined { + const sectionId = useContext(InfoSectionCollapseIdContext); const [collapsedIds, setCollapsedIds] = useAtom(collapsedInfoSectionIdsAtom); - const collapsed = collapsedIds.includes(sectionId); const setCollapsed = useCallback( (nextCollapsed: boolean) => { + if (sectionId === null) return; setCollapsedIds((ids) => { const without = ids.filter((id) => id !== sectionId); return nextCollapsed ? [...without, sectionId] : without; @@ -19,5 +24,6 @@ export function useInfoSectionCollapse(sectionId: string): InfoSectionCollapse { }, [sectionId, setCollapsedIds], ); - return { collapsed, setCollapsed }; + if (sectionId === null) return undefined; + return { collapsed: collapsedIds.includes(sectionId), setCollapsed }; } diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx index fe48ba962ee..16863cb605e 100644 --- a/apps/app/src/components/tools/PluginCapabilities.tsx +++ b/apps/app/src/components/tools/PluginCapabilities.tsx @@ -290,6 +290,18 @@ function pluginAppSurfaceItems( "thread-header", "Adds an action to thread headers.", ), + ...namedSlotItems( + pluginId, + slots.threadInfoRows, + "thread-info-row", + "Adds a row to the thread Info tab.", + ), + ...namedSlotItems( + pluginId, + slots.threadInfoSections, + "thread-info-section", + "Adds a section to the thread Info tab.", + ), ...namedSlotItems( pluginId, slots.threadActions, diff --git a/apps/app/src/components/ui/detail-card.stories.tsx b/apps/app/src/components/ui/detail-card.stories.tsx index dd5737f1493..9ca1ee17553 100644 --- a/apps/app/src/components/ui/detail-card.stories.tsx +++ b/apps/app/src/components/ui/detail-card.stories.tsx @@ -50,7 +50,7 @@ export function Overview() { - +
- - Direct - - + Direct + - + - +
- + - + - + -
{label}
-
{children}
-
- ); - } - return (
-
{label}
-
{children}
+
+ {icon === undefined ? ( + label + ) : ( + {label} + )} +
+
{children}
); } diff --git a/apps/app/src/components/workspace/ChangedFilesDetailRow.tsx b/apps/app/src/components/workspace/ChangedFilesDetailRow.tsx index 763eec2da1a..914cd11d60f 100644 --- a/apps/app/src/components/workspace/ChangedFilesDetailRow.tsx +++ b/apps/app/src/components/workspace/ChangedFilesDetailRow.tsx @@ -1,4 +1,8 @@ -import { DetailRow } from "@/components/ui/detail-card.js"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + DETAIL_LABEL_CLASS, + DETAIL_VALUE_CLASS, +} from "@/components/ui/detail-card.js"; import { WorkspaceChangesList } from "@/components/thread/WorkspaceChangesList"; import { renderChangeSummary, @@ -20,8 +24,8 @@ export function ChangedFilesDetailRow({ rowValueClassName, }: ChangedFilesDetailRowProps) { return ( - +
{section.label} @@ -30,16 +34,14 @@ export function ChangedFilesDetailRow({ {renderChangeSummary(toChangeTally(section.stats))} - } - orientation="vertical" - className={rowClassName} - valueClassName={rowValueClassName} - > - - +
+
+ +
+
); } diff --git a/apps/app/src/lib/grouped-placement.ts b/apps/app/src/lib/grouped-placement.ts new file mode 100644 index 00000000000..a49d337fa6f --- /dev/null +++ b/apps/app/src/lib/grouped-placement.ts @@ -0,0 +1,17 @@ +export interface GroupedPlacement { + group: string; + order?: number; +} + +export function compareGroupedPlacement( + left: GroupedPlacement, + leftIndex: number, + right: GroupedPlacement, + rightIndex: number, +): number { + if (left.group !== right.group) return left.group < right.group ? -1 : 1; + const leftOrder = left.order ?? Number.POSITIVE_INFINITY; + const rightOrder = right.order ?? Number.POSITIVE_INFINITY; + if (leftOrder !== rightOrder) return leftOrder < rightOrder ? -1 : 1; + return leftIndex - rightIndex; +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index a8a1dc49add..613a5f3a36e 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -1,7 +1,11 @@ import { ProviderIcon } from "@/components/plugin/ProviderIcon"; import { Icon } from "@bb/shared-ui/icon"; import { useCallback, useMemo } from "react"; -import type { MarkdownProps, PluginSdkApp } from "@get-bb/plugin-sdk"; +import type { + ExperimentalInfoListRowProps, + MarkdownProps, + PluginSdkApp, +} from "@get-bb/plugin-sdk"; import { PluginDiff } from "@/components/plugin/PluginDiff"; import { PluginBranchPicker } from "@/components/plugin/PluginBranchPicker"; import { @@ -65,6 +69,8 @@ import { useAppNavigationHost } from "./app-navigation-host"; import { experimental_PluginRpcCallError, experimental_THREAD_ACTION_GROUPS, + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, } from "@get-bb/plugin-sdk"; import { ThreadActionsContextMenu, @@ -83,6 +89,24 @@ import { useSidebarNavigationSplit, } from "./plugin-sidebar-navigation"; import { SidebarNavigationIcon } from "@/components/sidebar/SidebarNavigationModel"; +import { + InfoList, + InfoListRow, + InfoSection, +} from "@/components/secondary-panel/info/info-list"; +import { DetailRow } from "@/components/ui/detail-card"; + +function PluginInfoListRow({ + onSelect, + ...props +}: ExperimentalInfoListRowProps) { + return ( + + ); +} function useArchiveEnvironmentThreads(): ( environmentId: string, @@ -124,6 +148,10 @@ export const pluginSdkAppImplementation = { experimental_useCheckoutState: usePluginCheckoutState, experimental_SourceCode: PluginSourceCode, experimental_Diff: PluginDiff, + experimental_InfoSection: InfoSection, + experimental_DetailRow: DetailRow, + experimental_InfoList: InfoList, + experimental_InfoListRow: PluginInfoListRow, experimental_useSidebarThreads: useSidebarThreads, experimental_useSidebarThreadActions: useSidebarThreadActions, experimental_useThreadActions: useThreadActionEntries, @@ -133,6 +161,8 @@ export const pluginSdkAppImplementation = { experimental_ThreadActionsContextMenu: ThreadActionsContextMenu, experimental_PluginRpcCallError, experimental_THREAD_ACTION_GROUPS, + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, experimental_useSidebarThreadPullRequest: useSidebarThreadPullRequest, experimental_useSidebarThreadSplit: useSidebarThreadSplit, experimental_useSidebarNavigation: useSidebarNavigation, diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index 817872b2221..2396f20dbf7 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -19,6 +19,8 @@ import type { PluginSidebarFooterActionRegistration, PluginSourceCodeRendererRegistration, PluginThreadHeaderActionRegistration, + ExperimentalThreadInfoRowRegistration, + ExperimentalThreadInfoSectionRegistration, PluginThreadActionRegistration, ExperimentalPluginBrowserToolbarActionRegistration, PluginThreadListRegistration, @@ -50,6 +52,8 @@ export interface PluginRegistrationSet { threadLists?: readonly PluginThreadListRegistration[]; experimentalSidebarNavigations?: readonly CollectedSidebarNavigationRegistration[]; threadHeaderActions?: readonly PluginThreadHeaderActionRegistration[]; + threadInfoRows?: readonly ExperimentalThreadInfoRowRegistration[]; + threadInfoSections?: readonly ExperimentalThreadInfoSectionRegistration[]; threadActions?: readonly PluginThreadActionRegistration[]; browserToolbarActions?: readonly ExperimentalPluginBrowserToolbarActionRegistration[]; fileOpeners: readonly PluginFileOpenerRegistration[]; @@ -95,6 +99,10 @@ export interface ExperimentalSidebarNavigationSlot extends CollectedSidebarNavigationRegistration, PluginSlotBase {} interface PluginThreadHeaderActionSlot extends PluginThreadHeaderActionRegistration, PluginSlotBase {} +export interface PluginThreadInfoRowSlot + extends ExperimentalThreadInfoRowRegistration, PluginSlotBase {} +export interface PluginThreadInfoSectionSlot + extends ExperimentalThreadInfoSectionRegistration, PluginSlotBase {} export interface PluginThreadActionSlot extends PluginThreadActionRegistration, PluginSlotBase {} export interface PluginBrowserToolbarActionSlot @@ -136,6 +144,8 @@ export interface PluginSlotSnapshot { threadLists: readonly PluginThreadListSlot[]; experimentalSidebarNavigations: readonly ExperimentalSidebarNavigationSlot[]; threadHeaderActions: readonly PluginThreadHeaderActionSlot[]; + threadInfoRows: readonly PluginThreadInfoRowSlot[]; + threadInfoSections: readonly PluginThreadInfoSectionSlot[]; threadActions: readonly PluginThreadActionSlot[]; browserToolbarActions: readonly PluginBrowserToolbarActionSlot[]; fileOpeners: readonly PluginFileOpenerSlot[]; @@ -165,6 +175,8 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { threadLists: [], experimentalSidebarNavigations: [], threadHeaderActions: [], + threadInfoRows: [], + threadInfoSections: [], threadActions: [], browserToolbarActions: [], fileOpeners: [], @@ -201,6 +213,8 @@ const SLOT_KINDS: readonly SlotKind[] = [ "threadLists", "experimentalSidebarNavigations", "threadHeaderActions", + "threadInfoRows", + "threadInfoSections", "threadActions", "browserToolbarActions", "fileOpeners", @@ -261,6 +275,8 @@ function flattenRegistrations( threadLists: stamp(set.threadLists), experimentalSidebarNavigations: stamp(set.experimentalSidebarNavigations), threadHeaderActions: stamp(set.threadHeaderActions), + threadInfoRows: stamp(set.threadInfoRows), + threadInfoSections: stamp(set.threadInfoSections), threadActions: stamp(set.threadActions), browserToolbarActions: stamp(set.browserToolbarActions), fileOpeners: stamp(set.fileOpeners), diff --git a/apps/app/src/lib/thread-actions/thread-action-registry.tsx b/apps/app/src/lib/thread-actions/thread-action-registry.tsx index 1f2ffeceba4..7b4f27e9ff5 100644 --- a/apps/app/src/lib/thread-actions/thread-action-registry.tsx +++ b/apps/app/src/lib/thread-actions/thread-action-registry.tsx @@ -23,6 +23,7 @@ import type { import { PluginContext } from "@/components/plugin/plugin-context"; import { getActiveThreadPanelOpener } from "@/components/plugin/plugin-thread-panel-navigation"; import { useBbNavigate, useSdk } from "@/lib/plugin-navigation-hooks"; +import { compareGroupedPlacement } from "@/lib/grouped-placement"; import { usePluginSlots } from "@/lib/plugin-slots"; export const CORE_THREAD_ACTION_OWNER = "bb--core"; @@ -294,7 +295,7 @@ function useThreadActionRecords( return records .map((record, index) => ({ record, index })) .sort((left, right) => - compareThreadActionPlacement( + compareGroupedPlacement( left.record.registration, left.index, right.record.registration, @@ -368,19 +369,6 @@ export function bindThreadAction( }; } -export function compareThreadActionPlacement( - left: { group: string; order?: number }, - leftIndex: number, - right: { group: string; order?: number }, - rightIndex: number, -): number { - if (left.group !== right.group) return left.group < right.group ? -1 : 1; - const leftOrder = left.order ?? Number.POSITIVE_INFINITY; - const rightOrder = right.order ?? Number.POSITIVE_INFINITY; - if (leftOrder !== rightOrder) return leftOrder < rightOrder ? -1 : 1; - return leftIndex - rightIndex; -} - function evaluateRecord( record: ThreadActionRegistrationRecord, thread: PluginThreadActionTarget, diff --git a/apps/app/src/test/fixtures/plugins.ts b/apps/app/src/test/fixtures/plugins.ts index 401a86ac5c2..832092ed733 100644 --- a/apps/app/src/test/fixtures/plugins.ts +++ b/apps/app/src/test/fixtures/plugins.ts @@ -33,6 +33,8 @@ export function makePluginRegistrationSet( sidebarFooterActions: [], threadLists: [], threadHeaderActions: [], + threadInfoRows: [], + threadInfoSections: [], threadActions: [], browserToolbarActions: [], fileOpeners: [], diff --git a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index 5a29e0f1921..9a92e9e7579 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -35,6 +35,7 @@ import { type PluginSidebarFooterActionProps, type PluginSourceCodeRendererProps, type PluginThreadHeaderActionProps, + type ExperimentalThreadInfoProps, type PluginThreadActionItemInput, type ExperimentalPluginBrowserToolbarActionProps, type PluginThreadListProps, @@ -278,6 +279,8 @@ type SlotPropsByName = { experimental_threadList: PluginThreadListProps; experimental_sidebarNavigation: ExperimentalSidebarNavigationProps; experimental_threadHeaderAction: PluginThreadHeaderActionProps; + experimental_threadInfoRow: ExperimentalThreadInfoProps; + experimental_threadInfoSection: ExperimentalThreadInfoProps; experimental_threadAction: PluginThreadActionItemInput; experimental_browserToolbarAction: ExperimentalPluginBrowserToolbarActionProps; fileOpener: PluginFileOpenerProps; @@ -366,6 +369,8 @@ const FRONTEND_SLOT_PROP_FIELDS = { "projectId", "isCompactViewport", ], + experimental_threadInfoRow: ["threadId", "projectId"], + experimental_threadInfoSection: ["threadId", "projectId"], experimental_threadAction: ["thread", "data", "sdk", "navigate"], experimental_browserToolbarAction: [ "threadId", diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 6fafb26299a..26d4d4693b3 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2411,6 +2411,56 @@ Docs: `frontend-registration.md`. that split, and decide whether other multi-mount slots need the same treatment. +## `app.slots.experimental_threadInfoRow` / `app.slots.experimental_threadInfoSection` / `experimental_THREAD_INFO_ROW_GROUPS` / `experimental_THREAD_INFO_SECTION_GROUPS` / `experimental_InfoSection` / `experimental_DetailRow` / `experimental_InfoList` / `experimental_InfoListRow` (`@get-bb/plugin-sdk/app`) + +A registration is `{ id, group, order?, component }`. The component receives +`{ threadId, projectId }` and mounts once per visible thread's Info tab. Rows +render in the details block, sections below it, each sorted the way thread +actions are: by `group` (string compare), then `order` within the group (unset +last), then registration order with core first on a tie. bb's groups are the +two exported tables; core uses orders 10, 20, 30 within each. Groups draw no +separator. Each plugin +entry mounts in its own crash boundary keyed by thread, so a crash drops only +that row or section. The four components are the Info tab's own building +blocks: `experimental_InfoSection` (heading, count pill, collapse), +`experimental_DetailRow` (label/value row with an optional icon), +`experimental_InfoList` (the "N more" list), and `experimental_InfoListRow`. +Inside a section slot, `experimental_InfoSection` collapses, and the state is +stored in the `infoPanel.collapsedSections` preference under the core id or +`/`. + +**Core callers.** `ThreadMetadataContent` renders the Info tab only through +`ThreadInfoRows` and `ThreadInfoSections` +(`apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx`), which +merge `CORE_THREAD_INFO_ROWS` and `CORE_THREAD_INFO_SECTIONS` with plugin +registrations. `experimental_DetailRow` is bb's only label/value row +(`DetailRow` in `apps/app/src/components/ui/detail-card.tsx`): all eleven core +rows and the commit dialog draw with it, and core's five sections draw with +`InfoSection` and `InfoList`. The +Automations plugin registers the Automations section. Contract tests: +`ThreadInfoSlots.test.tsx`. + +Docs: `frontend-registration.md`, `frontend-components.md`. + +**Audit before stabilizing.** + +1. **Props.** Components get only ids, so a plugin row that needs the thread's + environment or workspace status has no SDK read for it; core rows read the + Info tab's host state instead. Decide whether the props should carry a + thread snapshot before a second plugin needs one. +2. **Group boundaries.** Rows are grouped thread, environment, git, pull + request, lifecycle; sections related, changes, storage. Confirm these are + the groups to freeze, since moving a core entry between groups moves the + plugin entries that anchor to it. +3. **Empty state.** The Info tab still decides between its details, a loading + skeleton, and "No thread details available." from core state + (`hasAnyThreadMetadata`), so plugin entries never count toward it and are + hidden with core's details. Decide whether registrations should report + visibility. +4. **Separators.** The Info tab draws section separators on every + `[data-info-section]`, so a section that renders without + `experimental_InfoSection` gets no separator. Confirm that is acceptable. + ## `app.slots.experimental_threadAction` / `experimental_useThreadActions` / `experimental_useThreadActionRegistrations` / `experimental_ThreadActionsMenu` / `experimental_ThreadActionsContextMenu` / `experimental_THREAD_ACTION_GROUPS` (`@get-bb/plugin-sdk/app`) A registration is `{ id, title, icon, group, order?, useData?, item }`. diff --git a/docs/configuration.md b/docs/configuration.md index 7d6a206ea3d..9c540ace6bb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -719,7 +719,7 @@ list|get|set|reset` or `sdk.system.uiPreferences` (see | `sidebar.visiblePluginPanels` | Navigation destinations shown, or `null` for every destination | | `sidebar.footerOrder` | Footer action order | | `sidebar.hiddenFooterItems` | Footer actions moved into More | -| `infoPanel.collapsedSections` | Collapsed thread Info panel sections (`commits`, `uncommittedChanges`, `childThreads`, `forks`, `threadStorage`) | +| `infoPanel.collapsedSections` | Collapsed thread Info panel sections (`commits`, `uncommittedChanges`, `childThreads`, `forks`, `threadStorage`, or `/`) | A legacy `__builtin__` thread list selection resolves to the bundled plugin. Footer IDs are `builtin:settings` (top list layout only), `builtin:mobile`, diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 68815f7c107..1a316cbd9b9 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -1,3 +1,3 @@ -export const PLUGIN_SDK_VERSION = "0.6.69"; +export const PLUGIN_SDK_VERSION = "0.6.70"; export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/domain/src/ui-preferences.ts b/packages/domain/src/ui-preferences.ts index e0b4780b1b5..96b025c1830 100644 --- a/packages/domain/src/ui-preferences.ts +++ b/packages/domain/src/ui-preferences.ts @@ -151,7 +151,7 @@ export const uiPreferenceDefinitions = { "infoPanel.collapsedSections": defineUiPreference( uiPreferenceStringListSchema, [], - "Thread Info panel section ids that are collapsed, such as commits, uncommittedChanges, forks, or threadStorage.", + "Thread Info panel section ids that are collapsed, such as commits, uncommittedChanges, forks, threadStorage, or / for plugin sections.", ), } as const satisfies Record; diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index c94a7297ed2..397aefcadd1 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.6.69", + "version": "0.6.70", "homepage": "https://github.com/get-bb/bb#readme", "bugs": { "url": "https://github.com/get-bb/bb/issues" diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 78305baeb70..5860ce817dd 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1763,6 +1763,123 @@ export interface PluginThreadHeaderActionRegistration { component: ComponentType; } +/** + * Props passed to an `experimental_threadInfoRow` or + * `experimental_threadInfoSection` component, rendered in a thread's Info tab. + */ +export interface ExperimentalThreadInfoProps { + /** + * The thread whose Info tab is open. A split layout renders one Info tab per + * pane, so the component mounts once per visible thread; keep per-thread + * state in the component, never in a module-level singleton. + */ + threadId: string; + projectId: string; +} + +/** + * A row in the thread Info tab's details block, beside bb's own rows. Render + * one or more `experimental_DetailRow` elements, or null to show nothing for + * this thread. A crash removes only this row for that thread. + */ +export interface ExperimentalThreadInfoRowRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** + * Rows sort by `group` (string compare), then by `order` within a group + * (unset sorts last), then by registration order, with bb's own rows first + * on a tie. bb's groups are + * {@link PluginSdkApp.experimental_THREAD_INFO_ROW_GROUPS}; any other + * string is a group of its own, placed where it sorts. Groups only order + * rows; the details block draws no separator between them. + */ + group: string; + order?: number; + component: ComponentType; +} + +/** + * A section in the thread Info tab, below the details block. Render one + * `experimental_InfoSection`, or null to hide the section for this thread. + * The host remembers the section's collapsed state per registration. A crash + * removes only this section for that thread. + */ +export interface ExperimentalThreadInfoSectionRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** + * Sections sort by `group` (string compare), then by `order` within a group + * (unset sorts last), then by registration order, with bb's own sections + * first on a tie. bb's groups are + * {@link PluginSdkApp.experimental_THREAD_INFO_SECTION_GROUPS}; any other + * string is a group of its own, placed where it sorts. + */ + group: string; + order?: number; + component: ComponentType; +} + +/** + * Props of the host-owned `experimental_InfoSection` component — the heading, + * count, and collapse control every Info tab section uses. + */ +export interface ExperimentalInfoSectionProps { + label: string; + /** Drawn as a pill after the label; omit for no count. */ + count?: number; + /** Drawn after the label and count while the section is expanded. */ + accessory?: ReactNode; + /** Drawn at the heading's trailing edge while the section is expanded. */ + trailing?: ReactNode; + children: ReactNode; +} + +/** + * Props of the host-owned `experimental_DetailRow` component — one label and + * value pair in the Info tab's details block. + */ +export interface ExperimentalDetailRowProps { + label: ReactNode; + /** Icon drawn before the label, any name `experimental_Icon` accepts. */ + icon?: BbIconName; + /** Vertical alignment of label and value; defaults to `center`. */ + align?: "center" | "start"; + children: ReactNode; +} + +/** + * Props of the host-owned `experimental_InfoList` component — the list every + * Info tab section uses, collapsed to `limit` rows behind a "N more" toggle. + */ +export interface ExperimentalInfoListProps { + items: readonly Item[]; + getKey(item: Item): string; + /** Usually an `experimental_InfoListRow`. */ + renderItem(item: Item): ReactNode; + /** Rows shown before the "N more" toggle; defaults to 5. */ + limit?: number; +} + +/** + * Props of the host-owned `experimental_InfoListRow` component — one row of an + * `experimental_InfoList`. + */ +export interface ExperimentalInfoListRowProps { + /** Drawn in the row's 12px leading column, such as an `experimental_Icon`. */ + leading: ReactNode; + /** Tooltip and accessible name for `leading`; omit when it is decorative. */ + leadingLabel?: string; + name: ReactNode; + /** Native tooltip on the name. */ + title?: string; + /** Muted text after the name, truncated from the start. */ + context?: string; + /** Drawn at the row's trailing edge, such as a status. */ + trailing?: ReactNode; + /** Runs when the user activates the row; omit for a static row. */ + onSelect?: () => void; +} + export interface ExperimentalPluginBrowserToolbarActionRegistration { /** Unique within the plugin; letters, digits, `-`, `_`. */ id: string; @@ -2641,6 +2758,22 @@ export interface PluginAppSlots { experimental_threadHeaderAction( registration: PluginThreadHeaderActionRegistration, ): void; + /** + * Add a row to the thread Info tab's details block (see + * {@link ExperimentalThreadInfoRowRegistration}). Experimental: see + * docs/api_to_audit.md. + */ + experimental_threadInfoRow( + registration: ExperimentalThreadInfoRowRegistration, + ): void; + /** + * Add a section to the thread Info tab (see + * {@link ExperimentalThreadInfoSectionRegistration}). Experimental: see + * docs/api_to_audit.md. + */ + experimental_threadInfoSection( + registration: ExperimentalThreadInfoSectionRegistration, + ): void; /** * Add an action to every thread actions menu (see * {@link PluginThreadActionRegistration}). Experimental: see @@ -4275,6 +4408,31 @@ export interface PluginSdkApp { readonly settings: "3_settings"; readonly lifecycle: "4_lifecycle"; }; + /** + * bb's thread Info row groups, for an `experimental_threadInfoRow` that + * joins one: `thread` (Parent, Notify parent, Project), `environment` + * (Environment, Machine, provisioning failure, Directory), `git` (Branch, + * Merge base, Git status), `pullRequest`, and `lifecycle` (Archived). + * Experimental: see docs/api_to_audit.md. + */ + experimental_THREAD_INFO_ROW_GROUPS: { + readonly thread: "1_thread"; + readonly environment: "2_environment"; + readonly git: "3_git"; + readonly pullRequest: "4_pullRequest"; + readonly lifecycle: "5_lifecycle"; + }; + /** + * bb's thread Info section groups, for an `experimental_threadInfoSection` + * that joins one: `related` (Child threads, Forks), `changes` (Commits, + * Uncommitted files), and `storage` (Thread storage). Experimental: see + * docs/api_to_audit.md. + */ + experimental_THREAD_INFO_SECTION_GROUPS: { + readonly related: "1_related"; + readonly changes: "2_changes"; + readonly storage: "3_storage"; + }; /** * The pull request for one thread's branch (see * {@link PluginSidebarThreadPullRequestState}). @@ -4529,6 +4687,32 @@ export interface PluginSdkApp { * docs/api_to_audit.md. */ experimental_Diff: ComponentType; + /** + * The thread Info tab's section frame (see + * {@link ExperimentalInfoSectionProps}), the one bb's own sections use. + * Inside an `experimental_threadInfoSection` it collapses, and the host + * remembers that per registration. Experimental: see docs/api_to_audit.md. + */ + experimental_InfoSection: ComponentType; + /** + * The thread Info tab's label and value row (see + * {@link ExperimentalDetailRowProps}), the one bb's own rows use. + * Experimental: see docs/api_to_audit.md. + */ + experimental_DetailRow: ComponentType; + /** + * The thread Info tab's list (see {@link ExperimentalInfoListProps}), the + * one bb's own sections use. Experimental: see docs/api_to_audit.md. + */ + experimental_InfoList( + props: ExperimentalInfoListProps, + ): ReactNode; + /** + * One row of `experimental_InfoList` (see + * {@link ExperimentalInfoListRowProps}), the row bb's own sections use. + * Experimental: see docs/api_to_audit.md. + */ + experimental_InfoListRow: ComponentType; /** @internal Superseded by `useComposer()`; kept for plugins built against older SDKs. */ useComposerView(): ComposerView; } diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index bf176b85929..9373c704a67 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -148,6 +148,18 @@ export const experimental_SourceCode = runtimeComponent( "experimental_SourceCode", ); export const experimental_Diff = runtimeComponent("experimental_Diff"); +// The thread Info tab's building blocks (experimental — see +// docs/api_to_audit.md). +export const experimental_InfoSection = runtimeComponent( + "experimental_InfoSection", +); +export const experimental_DetailRow = runtimeComponent( + "experimental_DetailRow", +); +export const experimental_InfoList = runtimeComponent("experimental_InfoList"); +export const experimental_InfoListRow = runtimeComponent( + "experimental_InfoListRow", +); export const experimental_useRpcQuery = runtimeFunction( "experimental_useRpcQuery", ); @@ -210,6 +222,10 @@ export const experimental_ThreadActionsContextMenu = runtimeComponent( "experimental_ThreadActionsContextMenu", ); export { experimental_THREAD_ACTION_GROUPS } from "./thread-action-groups.js"; +export { + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, +} from "./thread-info-groups.js"; export const experimental_useSidebarThreadPullRequest = runtimeFunction( "experimental_useSidebarThreadPullRequest", ); diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index 110e99bc0d0..69539e37ebb 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -14,6 +14,10 @@ export * from "./host-contract.js"; export type * from "./json-value.js"; export * from "./rpc-contract.js"; export { experimental_THREAD_ACTION_GROUPS } from "./thread-action-groups.js"; +export { + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, +} from "./thread-info-groups.js"; export type { ExperimentalDesktopBrowsersArea, ExperimentalDesktopBrowserScope, diff --git a/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts index db3f8bd2aa2..3da3de878a1 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts @@ -29,6 +29,8 @@ import type { PluginSidebarFooterActionRegistration, PluginSourceCodeRendererRegistration, PluginThreadHeaderActionRegistration, + ExperimentalThreadInfoRowRegistration, + ExperimentalThreadInfoSectionRegistration, PluginThreadActionRegistration, ExperimentalPluginBrowserToolbarActionRegistration, ExperimentalSidebarHeaderProps, @@ -273,6 +275,8 @@ export interface CollectedPluginAppRegistrations { threadLists: PluginThreadListRegistration[]; experimentalSidebarNavigations: CollectedSidebarNavigationRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; + threadInfoRows: ExperimentalThreadInfoRowRegistration[]; + threadInfoSections: ExperimentalThreadInfoSectionRegistration[]; threadActions: PluginThreadActionRegistration[]; browserToolbarActions: ExperimentalPluginBrowserToolbarActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; @@ -294,6 +298,17 @@ export interface CollectedSidebarNavigationRegistration extends ExperimentalSide placement: ExperimentalSidebarNavigationPlacement; } +function requireOptionalOrder( + kind: string, + order: number | undefined, +): { order?: number } { + if (order === undefined) return {}; + if (!Number.isFinite(order)) { + throw new Error(`${kind}: "order" must be a finite number`); + } + return { order }; +} + function collectTitledComponent( kind: string, seen: Set, @@ -387,6 +402,8 @@ export function collectPluginAppRegistrations( threadLists: [], experimentalSidebarNavigations: [], threadHeaderActions: [], + threadInfoRows: [], + threadInfoSections: [], threadActions: [], browserToolbarActions: [], fileOpeners: [], @@ -419,6 +436,8 @@ export function collectPluginAppRegistrations( sidebarNavigation: new Set(), sidebarHeader: new Set(), threadHeaderAction: new Set(), + threadInfoRow: new Set(), + threadInfoSection: new Set(), threadAction: new Set(), browserToolbarAction: new Set(), fileOpener: new Set(), @@ -772,6 +791,28 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_threadInfoRow(registration) { + const kind = "slots.experimental_threadInfoRow"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.threadInfoRow, id); + collected.threadInfoRows.push({ + id, + group: requireNonEmptyString(kind, "group", registration.group), + ...requireOptionalOrder(kind, registration.order), + component: requireComponent(kind, registration.component), + }); + }, + experimental_threadInfoSection(registration) { + const kind = "slots.experimental_threadInfoSection"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.threadInfoSection, id); + collected.threadInfoSections.push({ + id, + group: requireNonEmptyString(kind, "group", registration.group), + ...requireOptionalOrder(kind, registration.order), + component: requireComponent(kind, registration.component), + }); + }, experimental_threadAction(registration) { const kind = "slots.experimental_threadAction"; const id = requireSlotId(kind, registration?.id); @@ -785,20 +826,12 @@ export function collectPluginAppRegistrations( ) { throw new Error(`${kind}: "useData" must be a function`); } - if ( - registration.order !== undefined && - !Number.isFinite(registration.order) - ) { - throw new Error(`${kind}: "order" must be a finite number`); - } collected.threadActions.push({ id, title: requireNonEmptyString(kind, "title", registration.title), icon: requireNonEmptyString(kind, "icon", registration.icon), group: requireNonEmptyString(kind, "group", registration.group), - ...(registration.order !== undefined - ? { order: registration.order } - : {}), + ...requireOptionalOrder(kind, registration.order), ...(registration.useData !== undefined ? { useData: registration.useData } : {}), diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index c1215c66118..087cc5baa2b 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -8,6 +8,7 @@ import { import * as React from "react"; import { createContext, + Fragment, useCallback, useContext, useLayoutEffect, @@ -86,6 +87,12 @@ import { type PluginSidebarThreadsState, type PluginSourceCodeRendererRegistration, type PluginThreadHeaderActionRegistration, + type ExperimentalThreadInfoRowRegistration, + type ExperimentalThreadInfoSectionRegistration, + type ExperimentalInfoSectionProps, + type ExperimentalDetailRowProps, + type ExperimentalInfoListProps, + type ExperimentalInfoListRowProps, type PluginThreadActionEntry, type PluginThreadActionRegistration, type PluginThreadActionRegistrationInfo, @@ -132,6 +139,10 @@ import { import { normalizePluginThreadRowStatus } from "../internal/composer-customization-validation.js"; import { normalizeExperimentalFileOpenOptions } from "../internal/file-navigation-validation.js"; import { experimental_THREAD_ACTION_GROUPS } from "../thread-action-groups.js"; +import { + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, +} from "../thread-info-groups.js"; import { collectPluginAppRegistrations, type CollectedPluginProviderIconRegistration, @@ -1219,6 +1230,78 @@ function TestDiff({ ); } +/** Stand-in for the Info tab section frame: a labelled section. */ +function TestInfoSection({ + label, + count, + accessory, + trailing, + children, +}: ExperimentalInfoSectionProps) { + return ( +
+

+ {label} + {count === undefined ? null : {count}} +

+ {accessory} + {trailing} + {children} +
+ ); +} + +/** Stand-in for the Info tab label and value row. */ +function TestDetailRow({ label, icon, children }: ExperimentalDetailRowProps) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** Stand-in for the Info tab list: every item, with the limit recorded. */ +function TestInfoList({ + items, + getKey, + renderItem, + limit, +}: ExperimentalInfoListProps) { + return ( +
    + {items.map((item) => ( + {renderItem(item)} + ))} +
+ ); +} + +/** Stand-in for one Info tab list row: a button when it can be selected. */ +function TestInfoListRow({ + leading, + name, + title, + context, + trailing, + onSelect, +}: ExperimentalInfoListRowProps) { + return ( +
  • + {leading} + {onSelect === undefined ? ( + {name} + ) : ( + + )} + {context === undefined ? null : {context}} + {trailing} +
  • + ); +} + type TestClipboard = ( content: ExperimentalClipboardContent, ) => Promise; @@ -1382,6 +1465,10 @@ const testPluginSdkApp = { }, experimental_SourceCode: TestSourceCode, experimental_Diff: TestDiff, + experimental_InfoSection: TestInfoSection, + experimental_DetailRow: TestDetailRow, + experimental_InfoList: TestInfoList, + experimental_InfoListRow: TestInfoListRow, experimental_useSidebarThreads(): PluginSidebarThreadsState { return useSlotEnv("experimental_useSidebarThreads").sidebarThreads; }, @@ -1433,6 +1520,8 @@ const testPluginSdkApp = { experimental_ThreadActionsContextMenu: TestThreadActionsContextMenu, experimental_PluginRpcCallError, experimental_THREAD_ACTION_GROUPS, + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, experimental_useSidebarNavigation(): ExperimentalSidebarNavigationState { return useSlotEnv("experimental_useSidebarNavigation").sidebarNavigation; }, @@ -1587,6 +1676,8 @@ export interface CapturedPluginApp { threadLists: PluginThreadListRegistration[]; experimentalSidebarNavigations: ExperimentalSidebarNavigationRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; + threadInfoRows: ExperimentalThreadInfoRowRegistration[]; + threadInfoSections: ExperimentalThreadInfoSectionRegistration[]; threadActions: PluginThreadActionRegistration[]; browserToolbarActions: ExperimentalPluginBrowserToolbarActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; diff --git a/packages/plugin-sdk/src/thread-info-groups.ts b/packages/plugin-sdk/src/thread-info-groups.ts new file mode 100644 index 00000000000..62d1b149d8c --- /dev/null +++ b/packages/plugin-sdk/src/thread-info-groups.ts @@ -0,0 +1,15 @@ +import type { PluginSdkApp } from "./app-contract.js"; + +export const experimental_THREAD_INFO_ROW_GROUPS = { + thread: "1_thread", + environment: "2_environment", + git: "3_git", + pullRequest: "4_pullRequest", + lifecycle: "5_lifecycle", +} as const satisfies PluginSdkApp["experimental_THREAD_INFO_ROW_GROUPS"]; + +export const experimental_THREAD_INFO_SECTION_GROUPS = { + related: "1_related", + changes: "2_changes", + storage: "3_storage", +} as const satisfies PluginSdkApp["experimental_THREAD_INFO_SECTION_GROUPS"]; diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index ca68d55e94c..5e0e1c78d37 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -4,9 +4,14 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { definePluginApp, + experimental_InfoList as InfoList, + experimental_InfoListRow as InfoListRow, + experimental_InfoSection as InfoSection, + experimental_THREAD_INFO_SECTION_GROUPS, useBbNavigate, useRealtime, useRpc, + type ExperimentalThreadInfoProps, type PluginNavPanelProps, } from "@get-bb/plugin-sdk/app"; import type { automationRpcContract } from "./src/rpc.js"; @@ -16,17 +21,23 @@ import type { AutomationDetailResponse, AutomationResponse, AgentExecutionUpdate, + AutomationReadResult, AutomationRunListResponse, AutomationRunResponse, AutomationsOverviewResponse, } from "./src/rpc-types"; -import { AutomationDetailView } from "./detail-view"; +import { AutomationDetailView, automationIconName } from "./detail-view"; import { AutomationOverviewView, + automationProblemLabel, automationProjectLabel, type AutomationCollectionMode, } from "./overview-view"; -import { PERSONAL_PROJECT_ID } from "./lib/format-schedule"; +import { + formatScheduleStatusLabel, + PERSONAL_PROJECT_ID, +} from "./lib/format-schedule"; +import { Icon } from "@/components/ui/icon"; import { buildAutomationEditThreadPrompt } from "./lib/edit-prompt"; import { Button } from "@/components/ui/button"; import { DelayedLoading } from "@/components/ui/delayed-loading"; @@ -214,6 +225,96 @@ function useAutomation(route: DetailRoute | null): { }; } +const THREAD_INFO_AUTOMATION_LIMIT = 3; + +function useTargetThreadAutomations( + threadId: string, +): readonly AutomationReadResult[] { + const rpc = useRpc(); + const queryKey = ["automations-targeting-thread", threadId]; + const query = useQuery( + { + queryKey, + queryFn: () => rpc.call("automations_list", { targetThreadId: threadId }), + staleTime: 30_000, + retry: false, + }, + automationQueryClient, + ); + useRealtime("automations", (payload) => { + if (asSignal(payload)?.kind === "automations-changed") { + void automationQueryClient.invalidateQueries( + { queryKey }, + { cancelRefetch: false }, + ); + } + }); + return query.data ?? []; +} + +function AutomationThreadInfoRow({ + automation, +}: { + automation: AutomationReadResult; +}) { + const navigate = useBbNavigate(); + const problem = "problem" in automation; + const repairable = problem && automation.problem === "missing-agent-prompt"; + const status = problem + ? automationProblemLabel(automation) + : formatScheduleStatusLabel(automation); + return ( + + } + name={automation.name} + title={automation.name} + trailing={ + + {status} + + } + onSelect={() => + navigate.toPluginPanel(PANEL_PATH, { + subPath: `${automation.projectId}/${automation.id}${ + repairable ? "/edit" : "" + }`, + }) + } + /> + ); +} + +function AutomationsThreadInfoSection({ + threadId, +}: ExperimentalThreadInfoProps) { + const automations = useTargetThreadAutomations(threadId); + if (automations.length === 0) return null; + return ( + + automation.id} + limit={THREAD_INFO_AUTOMATION_LIMIT} + renderItem={(automation) => ( + + )} + /> + + ); +} + function useAutomationsPageTitle({ subPath }: PluginNavPanelProps) { const parsed = parseSubPath(subPath); const { automation } = useAutomation(parsed?.route ?? null); @@ -779,4 +880,10 @@ export default definePluginApp((app) => { component: AutomationsPanel, experimental_usePageTitle: useAutomationsPageTitle, }); + app.slots.experimental_threadInfoSection({ + id: "automations", + group: experimental_THREAD_INFO_SECTION_GROUPS.related, + order: 0, + component: AutomationsThreadInfoSection, + }); }); diff --git a/plugins/automations/overview-view.tsx b/plugins/automations/overview-view.tsx index f056fca45e2..6aa2867cd60 100644 --- a/plugins/automations/overview-view.tsx +++ b/plugins/automations/overview-view.tsx @@ -402,7 +402,9 @@ function AutomationProblemRow({ ); } -function automationProblemLabel(automation: AutomationReadProblem): string { +export function automationProblemLabel( + automation: AutomationReadProblem, +): string { return automation.problem === "missing-agent-prompt" ? "Prompt required" : "Invalid data"; diff --git a/plugins/automations/skills/automations/SKILL.md b/plugins/automations/skills/automations/SKILL.md index 4c18ab25380..22d4144c832 100644 --- a/plugins/automations/skills/automations/SKILL.md +++ b/plugins/automations/skills/automations/SKILL.md @@ -40,6 +40,7 @@ Managing: ```bash bb automation list --project +bb automation list --target-thread bb automation show --project bb automation update --project [--name ] [schedule flags] [complete execution flags | partial agent update flags] bb automation pause --project @@ -49,6 +50,10 @@ bb automation runs --project [--limit ] [--output --project --yes ``` +`list --target-thread` returns automations in any project that re-prompt that +thread; the thread's Info tab shows the same list. It does not include +automations the thread merely created. + For partial updates, mode replacement, execution targets, or damaged records, read [references/updates.md](references/updates.md). Every command supports `--json`. diff --git a/plugins/automations/src/cli.ts b/plugins/automations/src/cli.ts index b03854153a1..49e347b8fbd 100644 --- a/plugins/automations/src/cli.ts +++ b/plugins/automations/src/cli.ts @@ -1068,13 +1068,40 @@ export function registerAutomationCli(args: { }), commands: { list: cliCommand({ - summary: "List automations for a project", - options: { project: PROJECT_OPTION, json: JSON_OPTION }, + summary: "List automations for a project or a target thread", + options: { + project: { + type: "string", + placeholder: "id", + description: + "Project whose automations to list; required unless --target-thread is given", + }, + "target-thread": { + type: "string", + placeholder: "thread-id", + description: + "List automations in any project that re-prompt this thread", + }, + json: JSON_OPTION, + }, + constraints: [ + { kind: "at-most-one", options: ["project", "target-thread"] }, + ], run: (input, ctx) => attempt(async () => { - const result = service.list({ - projectId: requireProjectId(input.options.project, ctx), - }); + const targetThreadId = input.options["target-thread"]; + const result = service.list( + targetThreadId === undefined + ? { + projectId: requireProjectId(input.options.project, ctx), + } + : { + targetThreadId: requireOptionValue( + "target-thread", + targetThreadId, + ), + }, + ); return { exitCode: 0, stdout: input.options.json diff --git a/plugins/automations/src/data.ts b/plugins/automations/src/data.ts index c7583ecd429..c8f65d535a7 100644 --- a/plugins/automations/src/data.ts +++ b/plugins/automations/src/data.ts @@ -485,6 +485,22 @@ export function listAutomationsForProject( .map(requiredAutomationRow); } +export function listAutomationsTargetingThread( + db: Db, + targetThreadId: string, +): AutomationRow[] { + return db + .prepare( + `SELECT + ${AUTOMATION_COLUMNS} + FROM automations + WHERE target_thread_id = ? + ORDER BY created_at DESC, id DESC`, + ) + .all(targetThreadId) + .map(requiredAutomationRow); +} + export function listAllAutomations(db: Db): AutomationRow[] { return db .prepare( diff --git a/plugins/automations/src/rpc-types.ts b/plugins/automations/src/rpc-types.ts index 4b793b5d395..7b73f83092b 100644 --- a/plugins/automations/src/rpc-types.ts +++ b/plugins/automations/src/rpc-types.ts @@ -410,9 +410,11 @@ export const projectAutomationInputSchema = z }) .strict(); -export const listAutomationsInputSchema = z - .object({ projectId: z.string().min(1) }) - .strict(); +export const listAutomationsInputSchema = z.union([ + z.object({ projectId: z.string().min(1) }).strict(), + z.object({ targetThreadId: z.string().min(1) }).strict(), +]); +export type ListAutomationsInput = z.infer; export const createAutomationInputSchema = z .object({ diff --git a/plugins/automations/src/server-harness.test.ts b/plugins/automations/src/server-harness.test.ts index 46eb808d88a..1f903e78717 100644 --- a/plugins/automations/src/server-harness.test.ts +++ b/plugins/automations/src/server-harness.test.ts @@ -635,6 +635,52 @@ describe("automations server plugin harness", () => { await harness.dispose(); }); + it("lists only automations that re-prompt a thread, not ones it created", async () => { + const { harness } = await bootAutomationsPlugin(); + const targeted = await createAgentAutomation(harness, { + name: "Targets the thread", + targetThreadId: THREAD_ID, + }); + await harness.callRpc("automations_create", { + projectId: PROJECT_ID, + name: "Created by the thread", + enabled: true, + trigger: oneShotTrigger(), + execution: agentExecution(), + origin: "agent", + createdByThreadId: THREAD_ID, + }); + + const listed = automationListResponseSchema.parse( + await harness.callRpc("automations_list", { targetThreadId: THREAD_ID }), + ); + expect(listed.map((automation) => automation.id)).toEqual([targeted.id]); + + const cliList = await harness.runCli([ + "list", + "--target-thread", + THREAD_ID, + "--json", + ]); + expect(cliList.exitCode).toBe(0); + expect( + automationListResponseSchema + .parse(JSON.parse(cliList.stdout ?? "")) + .map((automation) => automation.id), + ).toEqual([targeted.id]); + + const conflicting = await harness.runCli([ + "list", + "--project", + PROJECT_ID, + "--target-thread", + THREAD_ID, + ]); + expect(conflicting.exitCode).not.toBe(0); + + await harness.dispose(); + }); + it.each([ { supported: ["accept-edits", "auto", "full"] as const, diff --git a/plugins/automations/src/service.ts b/plugins/automations/src/service.ts index 534e43e835d..c2d399f03ca 100644 --- a/plugins/automations/src/service.ts +++ b/plugins/automations/src/service.ts @@ -11,6 +11,7 @@ import { listAllAutomations, listAutomationRuns, listAutomationsForProject, + listAutomationsTargetingThread, setAutomationEnabled, toAutomationResponse, toAutomationRunResponse, @@ -43,6 +44,7 @@ import { type AutomationResponse, type AutomationScriptWorkingDirectory, type AutomationsOverviewResponse, + type ListAutomationsInput, type ResolvedCreateAutomationInput, type ResolvedAutomationRunsInput, type RunAutomationInput, @@ -78,7 +80,7 @@ type ServiceApi = Pick & { export interface AutomationService { overview(): Promise; - list(input: { projectId: string }): AutomationReadResult[]; + list(input: ListAutomationsInput): AutomationReadResult[]; get(input: { projectId: string; automationId: string; @@ -583,7 +585,11 @@ export function createAutomationService(args: { }, list(input) { - return listAutomationsForProject(db, input.projectId).map((row) => + const rows = + "projectId" in input + ? listAutomationsForProject(db, input.projectId) + : listAutomationsTargetingThread(db, input.targetThreadId); + return rows.map((row) => toStoredAutomationReadResult(bb, pluginDataDir, row), ); }, diff --git a/plugins/automations/src/thread-info-section.test.tsx b/plugins/automations/src/thread-info-section.test.tsx new file mode 100644 index 00000000000..61e6282b50b --- /dev/null +++ b/plugins/automations/src/thread-info-section.test.tsx @@ -0,0 +1,103 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/react"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import { afterEach, describe, expect, it } from "vitest"; +import type { AutomationResponse } from "./rpc-types.js"; + +const app = await loadPluginApp(() => import("../app")); + +afterEach(cleanup); + +function automation( + overrides: Partial = {}, +): AutomationResponse { + return { + id: "auto_1", + projectId: "proj_1", + name: "Check CI", + enabled: true, + trigger: { triggerType: "schedule", cron: "0 9 * * *", timezone: "UTC" }, + execution: { + mode: "agent", + prompt: "Check CI.", + providerId: "claude", + model: "claude-opus-5", + reasoningLevel: "medium", + permissionMode: "auto", + environment: { type: "project-default" }, + targetThreadId: "thr_1", + }, + origin: "agent", + createdByThreadId: null, + nextRunAt: null, + lastRunAt: null, + runCount: 0, + lastRunStatus: null, + lastRunThreadId: null, + lastError: null, + createdAt: 1_700_000_000_000, + updatedAt: 1_700_000_000_000, + ...overrides, + }; +} + +describe("automations thread Info section", () => { + it("lists the thread's automations, refreshes on any project's automation change but not run-only signals, and opens one", async () => { + const section = app.threadInfoSections[0]; + if (section === undefined) throw new Error("missing Info section"); + let listed: AutomationResponse[] = []; + const slot = renderSlot( + section, + { threadId: "thr_1", projectId: "proj_1" }, + { rpc: { automations_list: () => listed } }, + ); + + await waitFor(() => { + expect(slot.inspection.rpcCalls).toEqual([ + { method: "automations_list", input: { targetThreadId: "thr_1" } }, + ]); + }); + expect(screen.queryByRole("region", { name: "Automations" })).toBeNull(); + + await slot.behavior.emitRealtime("automations", { + projectId: "proj_1", + kind: "automation-runs-changed", + }); + expect(slot.inspection.rpcCalls).toHaveLength(1); + + listed = [ + automation({ + trigger: { triggerType: "once", runAt: 1_700_000_000_000 }, + enabled: false, + runCount: 1, + lastRunStatus: "succeeded", + }), + automation({ + id: "auto_2", + projectId: "proj_other", + name: "Paused check", + enabled: false, + }), + ]; + await slot.behavior.emitRealtime("automations", { + projectId: "proj_other", + kind: "automations-changed", + }); + + expect( + await screen.findByRole("region", { name: "Automations" }), + ).toBeTruthy(); + expect(screen.getByText("Completed")).toBeTruthy(); + expect(screen.getByText("Paused")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Paused check" })); + expect(slot.inspection.navigateCalls).toEqual([ + { + method: "toPluginPanel", + path: "automations", + options: { subPath: "proj_other/auto_2" }, + }, + ]); + }); +}); diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md index 0d40a2c46b8..c51c373fee9 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/backend-api-index.md @@ -284,6 +284,13 @@ Read the installed declarations for exact current signatures. - `PluginThreadEventPayloads` - `PluginThreadHeaderActionProps` - `PluginThreadHeaderActionRegistration` +- `ExperimentalThreadInfoProps` +- `ExperimentalThreadInfoRowRegistration` +- `ExperimentalThreadInfoSectionRegistration` +- `ExperimentalInfoSectionProps` +- `ExperimentalDetailRowProps` +- `ExperimentalInfoListProps` +- `ExperimentalInfoListRowProps` - `PluginThreadListProps` - `PluginThreadListRegistration` - `PluginThreadPanelActionContext` diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md index f2d03d89b81..4c9290a693f 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-api-index.md @@ -23,6 +23,10 @@ Read the installed SDK declarations for the exact current signatures. - `experimental_useCheckoutState` - `experimental_SourceCode` - `experimental_Diff` +- `experimental_InfoSection` +- `experimental_DetailRow` +- `experimental_InfoList` +- `experimental_InfoListRow` - `useRpc` - `experimental_useRpcQuery` - `experimental_useRpcKeyedQuery` @@ -48,6 +52,8 @@ Read the installed SDK declarations for the exact current signatures. - `experimental_ThreadActionsMenu` - `experimental_ThreadActionsContextMenu` - `experimental_THREAD_ACTION_GROUPS` +- `experimental_THREAD_INFO_ROW_GROUPS` +- `experimental_THREAD_INFO_SECTION_GROUPS` - `experimental_useSidebarThreadPullRequest` - `experimental_useSidebarThreadSplit` - `experimental_useSidebarNavigation` — the sidebar navigation items in the @@ -110,6 +116,11 @@ Read the installed SDK declarations for the exact current signatures. - `ExperimentalSidebarFooterDisclosureProps` - `PluginThreadListProps` - `PluginThreadHeaderActionProps` +- `ExperimentalThreadInfoProps` +- `ExperimentalInfoSectionProps` +- `ExperimentalDetailRowProps` +- `ExperimentalInfoListProps` +- `ExperimentalInfoListRowProps` - `ExperimentalPluginBrowserToolbarActionProps` - `ExperimentalPluginBrowserPage` - `ExperimentalPluginBrowserPageEvaluateOptions` @@ -181,6 +192,8 @@ Read the installed SDK declarations for the exact current signatures. - `PluginBoundThreadsArea` - `PluginBrowserBbSdk` - `PluginThreadHeaderActionRegistration` +- `ExperimentalThreadInfoRowRegistration` +- `ExperimentalThreadInfoSectionRegistration` - `PluginThreadActionTarget` - `PluginThreadActionChoice` - `PluginThreadActionChoices` diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md index cc35264ff1e..982331e42ca 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-components.md @@ -148,6 +148,18 @@ className? }` — not, so code there renders unhighlighted rather than broken. Experimental: see `docs/api_to_audit.md`. +- `experimental_InfoSection`, `experimental_DetailRow`, `experimental_InfoList`, + `experimental_InfoListRow` — the thread Info tab's building blocks, the ones + bb's own rows and sections use. `experimental_InfoSection` takes + `{ label, count?, accessory?, trailing?, children }` and collapses inside an + `experimental_threadInfoSection`. `experimental_DetailRow` takes + `{ label, icon?, align?, children }`, where `icon` is any name + `experimental_Icon` accepts. `experimental_InfoList` takes + `{ items, getKey, renderItem, limit? }`. `experimental_InfoListRow` takes + `{ leading, leadingLabel?, name, title?, context?, trailing?, onSelect? }`. + Usage is in frontend-registration.md. Experimental: see + `docs/api_to_audit.md`. + - `Markdown` — bb's chat-message markdown renderer (same typography, spacing, and code styling as timeline messages). Props: `{ content, className? }`. Use it wherever plugin UI quotes or previews diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md index 0d83c4f1a82..0ccc5032fd2 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-core-slots.md @@ -276,6 +276,7 @@ experimental_Original }` — draws the sidebar navigation. Registration: with `experimental_useSidebarNavigation()`. Exclusive; see frontend-sidebar-navigation.md. Experimental: see `docs/api_to_audit.md`. - `experimental_threadList`, `experimental_threadHeaderAction`, + `experimental_threadInfoRow`, `experimental_threadInfoSection`, `experimental_threadAction`, and `experimental_browserToolbarAction` are covered in frontend-registration.md. - `fileOpener` → `{ path: string, source, experimental_lineRange?, Original }` — register as a viewer/editor diff --git a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md index e791f419e63..3b4f1c7784d 100644 --- a/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md +++ b/plugins/bb-guide/skills/bb-plugin-authoring/references/frontend-registration.md @@ -169,6 +169,36 @@ A common pairing with a replaced sidebar: hide child threads from the list and surface them here instead, filtering `experimental_useSidebarThreads()` by `parentThreadId === threadId`. +### Rows and sections in the thread Info tab + +`app.slots.experimental_threadInfoRow` adds a row to the Info tab's details +block; `app.slots.experimental_threadInfoSection` adds a section below it. bb's +own rows and sections are registrations of the same `{ id, group, order?, +component }` shape, drawn with the SDK's Info components (frontend-components.md). + +```tsx +app.slots.experimental_threadInfoSection({ id: "automations", group: SECTION_GROUPS.related, order: 0, component: Automations }); +function Automations({ threadId }: ExperimentalThreadInfoProps) { + const items = useItems(threadId); + if (items.length === 0) return null; + return ( + + item.id} limit={3} + renderItem={(item) => } name={item.name} onSelect={...} />} /> + + ); +} +``` + +Components receive `{ threadId, projectId }` and mount once per visible +thread; return null to show nothing. A row renders `experimental_DetailRow`. +Entries sort by `group`, then `order` within it (unset last), then registration +order, bb's first on a tie, as thread actions do. Join one of bb's groups from +`experimental_THREAD_INFO_ROW_GROUPS` or `experimental_THREAD_INFO_SECTION_GROUPS` +(bb's entries use orders 10, 20, 30 within a group), or name your own group to +sort it where its string falls. Inside a section slot `experimental_InfoSection` collapses and bb +remembers it per registration. A crash removes only that row or section. + ### An action in every thread menu `app.slots.experimental_threadAction` adds an entry to the thread header's diff --git a/plugins/plugin-api-docs/src/anatomy-manifest.json b/plugins/plugin-api-docs/src/anatomy-manifest.json index ec70c5403d7..77860ac87a5 100644 --- a/plugins/plugin-api-docs/src/anatomy-manifest.json +++ b/plugins/plugin-api-docs/src/anatomy-manifest.json @@ -246,6 +246,56 @@ ] } ] + }, + "thread-info": { + "groupId": "app-shell", + "fidelity": "anchor", + "responsiveStrategy": "scale-together", + "requiredStates": ["anchor"], + "labels": { + "anchor": ["Project", "Branch", "Deploy", "Automations", "Commits"] + }, + "fixtureClassAnchors": [ + "grid-cols-[96px_minmax(0,1fr)]", + "mt-3", + "border-t", + "pt-3" + ], + "sources": [ + { + "path": "apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx", + "anchors": [ + "CORE_THREAD_INFO_ROWS", + "CORE_THREAD_INFO_SECTIONS", + " { appShellScene: "panel" as const, surfaces: group.surfaces.filter((surface) => [ + "thread-info", "code-renderers", "browser-toolbar", "thread-panel", diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index be6407437e1..85629117b4a 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -350,6 +350,34 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ ], firstParty: ["Ask User Question", "Pi provider", "Secrets"], }, + { + id: "thread-info", + title: "Thread Info rows and sections", + summary: + "Add a row to a thread's Info tab details, or a section below them. With this, a plugin can:", + bullets: [ + "Show plugin state for the thread beside Project, Environment, Branch, and Pull request", + 'List related items in a section with bb\'s heading, count, collapse, and "N more" list', + "Sit at any position among bb's own rows and sections, which use the same slots", + ], + apiSymbols: [ + "ExperimentalThreadInfoRowRegistration", + "ExperimentalThreadInfoSectionRegistration", + "ExperimentalThreadInfoProps", + "experimental_THREAD_INFO_ROW_GROUPS", + "experimental_THREAD_INFO_SECTION_GROUPS", + "experimental_InfoSection", + "ExperimentalInfoSectionProps", + "experimental_DetailRow", + "ExperimentalDetailRowProps", + "experimental_InfoList", + "ExperimentalInfoListProps", + "experimental_InfoListRow", + "ExperimentalInfoListRowProps", + ], + firstParty: ["Automations"], + experimental: true, + }, { id: "code-renderers", title: "Code & diff renderers", diff --git a/plugins/plugin-api-docs/src/wireframes.tsx b/plugins/plugin-api-docs/src/wireframes.tsx index d6340a1ed65..b390b27a494 100644 --- a/plugins/plugin-api-docs/src/wireframes.tsx +++ b/plugins/plugin-api-docs/src/wireframes.tsx @@ -642,6 +642,7 @@ export const ANATOMY_RENDERER_KEYS = { }; export type AppShellRightPanelTab = + | "thread-info" | "browser-toolbar" | "thread-panel" | "file-opener" @@ -653,6 +654,13 @@ function RightPanelTabLaneBadges({ mobile }: { mobile: boolean }) { : undefined; return ( <> + { if ( + expandedId === "thread-info" || expandedId === "browser-toolbar" || expandedId === "code-renderers" || expandedId === "thread-panel" || @@ -1348,6 +1357,93 @@ function AppShellWireframeBody({ ); } +const THREAD_INFO_FIXTURE_ROWS = [ + { icon: "Folder", label: "Project", value: "checkout-service" }, + { icon: "GitBranch", label: "Branch", value: "fix/retry-flake" }, +] as const; + +function ThreadInfoFixtureRow({ + icon, + label, + children, +}: { + icon: ReactNode; + label: string; + children: ReactNode; +}) { + return ( +
    + + {icon} + {label} + + {children} +
    + ); +} + +function ThreadInfoFixture() { + return ( +
    +
    + {THREAD_INFO_FIXTURE_ROWS.map((row) => ( + } + label={row.label} + > + {row.value} + + ))} + + } + label="Deploy" + > + Staging · healthy + + +
    + + + Automations + + 1 + + + + + + Nightly CI check + + + Next 9:00 AM + + + +
    + + Commits + + 2 + + + +
    +
    + ); +} + export function AppShellRightPanel({ compact = false, activeTab, @@ -1402,12 +1498,17 @@ export function AppShellRightPanel({ data-guide-fixture="right-panel-fixed-tabs" className="flex shrink-0 items-center gap-1.5" > - onTabSelect("thread-info")} > - - + + + + - {activeTab === "browser-toolbar" ? ( + {activeTab === "thread-info" ? ( + + ) : activeTab === "browser-toolbar" ? (
    diff --git a/plugins/plugin-api-docs/test/mobile-guide.test.ts b/plugins/plugin-api-docs/test/mobile-guide.test.ts index 04e941d48c2..6b91e1aeaf9 100644 --- a/plugins/plugin-api-docs/test/mobile-guide.test.ts +++ b/plugins/plugin-api-docs/test/mobile-guide.test.ts @@ -12,7 +12,7 @@ afterEach(() => { it.each([ ["app-shell", "thread-header", "app-shell-thread", "1", true], - ["app-shell", "browser-toolbar", "app-shell-panel", "2", true], + ["app-shell", "browser-toolbar", "app-shell-panel", "3", true], ["home", "new-thread-panel", "home-actions", "1", false], ] as const)( "keeps %s/%s on its matching pane when switching layouts", @@ -241,13 +241,14 @@ it("pages mobile panes without using annotation selection as navigation", () => [...current().querySelectorAll("[data-guide-badge]")].map( (badge) => badge.textContent, ), - ).toEqual(["1", "2", "3", "4"]); - openAnnotation("code-renderers"); + ).toEqual(["1", "2", "3", "4", "5"]); + openAnnotation("thread-info"); for (const [id, number, title] of [ - ["code-renderers", "1", "Code & diff renderers"], - ["browser-toolbar", "2", "Browser toolbar"], - ["thread-panel", "3", "Thread side-panel tabs"], - ["file-opener", "4", "File viewers & editors"], + ["thread-info", "1", "Thread Info rows and sections"], + ["code-renderers", "2", "Code & diff renderers"], + ["browser-toolbar", "3", "Browser toolbar"], + ["thread-panel", "4", "Thread side-panel tabs"], + ["file-opener", "5", "File viewers & editors"], ]) { expect( current().querySelector(`[data-guide-tab-body="${id}"]`), diff --git a/plugins/plugin-api-docs/test/surfaces.test.ts b/plugins/plugin-api-docs/test/surfaces.test.ts index 21566b3a2f8..c091e1fa256 100644 --- a/plugins/plugin-api-docs/test/surfaces.test.ts +++ b/plugins/plugin-api-docs/test/surfaces.test.ts @@ -27,6 +27,7 @@ describe("product-map surfaces", () => { "code-fence-renderers", "message-actions", "pending-interaction", + "thread-info", "code-renderers", "browser-toolbar", "thread-panel", diff --git a/plugins/plugin-api-docs/test/wireframes.test.ts b/plugins/plugin-api-docs/test/wireframes.test.ts index 94c3e43ecc9..924338f3670 100644 --- a/plugins/plugin-api-docs/test/wireframes.test.ts +++ b/plugins/plugin-api-docs/test/wireframes.test.ts @@ -211,7 +211,7 @@ describe("guide fixture boundaries", () => { const tabStrip = markup.slice(0, markup.indexOf("data-guide-tab-body=")); expect(tabStrip).toMatch( - /data-guide-fixture="right-panel-fixed-tabs"[\s\S]*data-guide-tab="info"[\s\S]*data-guide-tab="code-renderers"/, + /data-guide-fixture="right-panel-fixed-tabs"[\s\S]*data-guide-tab="thread-info"[\s\S]*data-guide-tab="code-renderers"/, ); expect(tabStrip).toMatch( /data-guide-fixture="right-panel-content-tabs"[\s\S]*data-guide-tab="thread-panel"[\s\S]*data-guide-tab="file-opener"/, @@ -268,6 +268,31 @@ describe("guide fixture boundaries", () => { ); }); + it("draws a plugin row and section among bb's own in the Info tab", () => { + const markup = renderWireframe( + createElement(AppShellRightPanel, { + activeTab: "thread-info", + onTabSelect: vi.fn(), + }), + ); + const contract = anatomy.surfaceFixtures["thread-info"]; + const body = markup.slice( + markup.indexOf('data-guide-fixture="thread-info"'), + ); + + for (const label of contract.labels.anchor) { + expect(body).toContain(label); + } + for (const classAnchor of contract.fixtureClassAnchors) { + expect(body, `missing fixture class ${classAnchor}`).toContain( + classAnchor, + ); + } + expect(body.indexOf("Branch")).toBeLessThan(body.indexOf("Deploy")); + expect(body.indexOf("Automations")).toBeLessThan(body.indexOf("Commits")); + expect(body.match(/data-guide-region="thread-info"/g)).toHaveLength(2); + }); + it("shows the app-wide plugin overlay above the host layout", () => { const markup = renderWireframe(createElement(AppShellWireframe)); const contract = anatomy.surfaceFixtures["app-overlay"];