From 63c6bc781a4efbdecd5cae5aba301839a11f0a00 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 22 Oct 2026 10:10:41 -0700 Subject: [PATCH 1/5] Add Info tab row and section slots that core uses The thread Info tab now renders only through two registries: app.slots.experimental_threadInfoRow (details block) and app.slots.experimental_threadInfoSection (below it). Core's eleven rows and five sections are registrations of the same shape, merged with plugin registrations by order, so the tab keeps today's order and behaviour. Info's own building blocks ship to plugins as experimental_InfoSection (heading, count, collapse), experimental_DetailRow, experimental_InfoList, and experimental_InfoListRow; core's rows and sections draw with the same components. Section collapse is keyed per registration (core ids unchanged, plugin sections as /). The Automations plugin registers the first plugin section: automations whose execution.targetThreadId is the open thread. automations_list and `bb automation list --target-thread` gain the target-thread filter. Fixes #5554 Co-Authored-By: Claude Opus 5.5 --- .../secondary-panel/ThreadMetadataContent.tsx | 423 ++++++++++-------- .../secondary-panel/info/ChangesSection.tsx | 3 - .../secondary-panel/info/CommitsSection.tsx | 4 +- .../info/RelatedThreadsSection.tsx | 13 +- .../info/ThreadInfoSlots.test.tsx | 143 ++++++ .../secondary-panel/info/ThreadInfoSlots.tsx | 108 +++++ .../info/ThreadStorageSection.tsx | 3 - .../secondary-panel/info/info-list.tsx | 24 +- .../info/useInfoSectionCollapse.ts | 14 +- .../components/tools/PluginCapabilities.tsx | 12 + apps/app/src/components/ui/detail-card.tsx | 30 ++ apps/app/src/lib/plugin-sdk-app-impl.tsx | 28 +- apps/app/src/lib/plugin-slots.ts | 16 + apps/app/src/test/fixtures/plugins.ts | 2 + .../plugins/plugin-authoring-docs.test.ts | 5 + docs/api_to_audit.md | 48 ++ docs/configuration.md | 2 +- packages/domain/src/plugin-sdk-version.ts | 2 +- packages/domain/src/ui-preferences.ts | 2 +- packages/plugin-sdk/package.json | 2 +- packages/plugin-sdk/src/app-contract.ts | 153 +++++++ packages/plugin-sdk/src/app.ts | 12 + .../plugin-app-registration-collector.ts | 49 +- packages/plugin-sdk/src/testing/app.tsx | 85 ++++ plugins/automations/app.tsx | 109 ++++- plugins/automations/overview-view.tsx | 4 +- .../automations/skills/automations/SKILL.md | 5 + plugins/automations/src/cli.ts | 37 +- plugins/automations/src/data.ts | 16 + plugins/automations/src/rpc-types.ts | 8 +- .../automations/src/server-harness.test.ts | 46 ++ plugins/automations/src/service.ts | 10 +- .../src/thread-info-section.test.tsx | 97 ++++ .../references/backend-api-index.md | 7 + .../references/frontend-api-index.md | 11 + .../references/frontend-components.md | 12 + .../references/frontend-core-slots.md | 1 + .../references/frontend-registration.md | 59 +++ .../plugin-api-docs/src/anatomy-manifest.json | 50 +++ plugins/plugin-api-docs/src/product-map.tsx | 1 + plugins/plugin-api-docs/src/surfaces.ts | 26 ++ plugins/plugin-api-docs/src/wireframes.tsx | 115 ++++- .../plugin-api-docs/test/mobile-guide.test.ts | 15 +- plugins/plugin-api-docs/test/surfaces.test.ts | 1 + .../plugin-api-docs/test/wireframes.test.ts | 27 +- 45 files changed, 1585 insertions(+), 255 deletions(-) create mode 100644 apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx create mode 100644 apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx create mode 100644 plugins/automations/src/thread-info-section.test.tsx diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index 6f70c1c0775..733ee73ab37 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -1,6 +1,8 @@ import { EnvironmentProviderIcon } from "@/components/plugin/EnvironmentProviderIcon"; import { + createContext, useCallback, + useContext, useEffect, useMemo, useRef, @@ -17,6 +19,12 @@ import { ThreadStorageSection, type ThreadStorageSectionProps, } from "./info/ThreadStorageSection"; +import { + ThreadInfoRows, + ThreadInfoSections, + type CoreThreadInfoRegistration, +} from "./info/ThreadInfoSlots"; +import type { ExperimentalThreadInfoProps } from "@get-bb/plugin-sdk"; import { Link } from "react-router-dom"; import type { Environment, @@ -52,6 +60,7 @@ import { DetailRow, DetailRowIconLabel, DETAIL_ROW_ICON_CLASS, + InfoDetailRow, } from "@/components/ui/detail-card.js"; import { useCreateThreadInEnvironment } from "@/hooks/useCreateThreadInEnvironment"; import { Icon } from "@bb/shared-ui/icon"; @@ -146,10 +155,7 @@ export function ParentSelectorRow({ } return ( - Parent} - valueClassName="min-w-0" - > + {parentThreadId ? (
)} - + ); } @@ -232,12 +238,7 @@ export function ParentNotificationsRow({ }); if (thread.parentThreadId === null) return null; return ( - Notify parent - } - valueClassName="min-w-0" - > + - + ); } @@ -309,7 +310,7 @@ export function EnvironmentRow({ infoDisplay.machineName !== null ? environmentDisplayHost.identity : null; return ( <> - ) } - valueClassName="min-w-0" > @@ -351,9 +351,9 @@ export function EnvironmentRow({ ) : null} - + {machineIdentity ? ( - Machine } - valueClassName="min-w-0" > (offline) )} - + ) : null} ); @@ -393,14 +392,11 @@ export function ProjectRow({ projectId }: { projectId: string }) { if (!projectName) return null; return ( - Project} - valueClassName="min-w-0" - > + {projectName} - + ); } @@ -411,21 +407,14 @@ export function EnvironmentProvisioningFailureRow({ }) { if (!failed) return null; return ( - - Environment - - } - valueClassName="min-w-0" - > + Not created · provisioning failed - + ); } @@ -457,12 +446,7 @@ function WorkspacePathValue({ path }: { path: string }) { ); return ( - Directory - } - valueClassName="min-w-0" - > +
{fitted}
-
+ ); } @@ -527,14 +511,7 @@ export function PullRequestRow({ pullRequest }: PullRequestRowProps) { const stateDisplay = getPullRequestStateDisplay(pullRequest); const nextStep = getPullRequestNextStep(pullRequest); return ( - - Pull request - - } - valueClassName="min-w-0" - > + ) : null} - + ); } @@ -691,12 +668,7 @@ export function MergeBaseRow({ ); return ( - Merge base - } - valueClassName="min-w-0" - > + {canSelectMergeBase && mergeBaseBranch ? ( {mergeBaseBranch} )} - + ); } @@ -755,12 +727,7 @@ export function GitStatusRow({ }).summary.replace(/\.$/, "")}. Showing the last known status.` : null; return ( - Git status - } - valueClassName="min-w-0" - > + {formatBranchComparison({ @@ -789,7 +756,7 @@ export function GitStatusRow({ ) : null} {!mergeBase.squashMergeCheckComplete ? : null} - + ); } @@ -799,13 +766,7 @@ export function GitStatusRow({ workspaceDeleted: environment?.status === "destroyed", }); return ( - Git status - } - align="start" - valueClassName="min-w-0" - > + - + ); } @@ -970,124 +931,216 @@ 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 CORE_THREAD_INFO_ROWS: readonly CoreThreadInfoRegistration[] = [ + { id: "parent", order: 100, component: CoreParentRow }, + { + id: "parentNotifications", + order: 200, + component: CoreParentNotificationsRow, + }, + { id: "project", order: 300, component: CoreProjectRow }, + { id: "environment", order: 400, component: CoreEnvironmentRow }, + { + id: "environmentProvisioningFailure", + order: 500, + component: CoreEnvironmentProvisioningFailureRow, + }, + { id: "directory", order: 600, component: CoreWorkspacePathRow }, + { id: "branch", order: 700, component: CoreBranchRow }, + { id: "mergeBase", order: 800, component: CoreMergeBaseRow }, + { id: "gitStatus", order: 900, component: CoreGitStatusRow }, + { id: "pullRequest", order: 1000, component: CorePullRequestRow }, + { id: "archived", order: 1100, component: CoreArchivedRow }, +]; + +const CORE_THREAD_INFO_SECTIONS: readonly CoreThreadInfoRegistration[] = [ + { id: "childThreads", order: 100, component: CoreChildThreadsSection }, + { id: "forks", order: 200, component: CoreForksSection }, + { id: "commits", order: 300, component: CoreCommitsSection }, + { + id: "uncommittedChanges", + order: 400, + component: CoreUncommittedChangesSection, + }, + { id: "threadStorage", order: 500, 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..0784dd5fbe6 --- /dev/null +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx @@ -0,0 +1,143 @@ +// @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 { InfoDetailRow } 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", order: 100, component: row("Core first") }, + { id: "second", order: 200, component: row("Core second") }, +]; + +const CORE_SECTIONS: CoreThreadInfoRegistration[] = [ + { id: "coreSection", order: 100, 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 among core's by order, unordered ones last", () => { + setPluginSlotRegistrations( + "demo", + makePluginRegistrationSet({ + threadInfoRows: [ + { id: "unordered", component: row("Plugin unordered") }, + { id: "between", order: 150, component: row("Plugin between") }, + ], + threadInfoSections: [ + { id: "late", component: section("Plugin late") }, + { id: "early", order: 50, component: section("Plugin early") }, + ], + }), + ); + renderInfo(); + + expect( + screen.getAllByRole("term").map((term) => term.textContent), + ).toEqual(["Core first", "Plugin between", "Core second", "Plugin unordered"]); + 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", order: 150, component: Crashes }], + threadInfoSections: [{ id: "broken", order: 50, component: Crashes }], + }), + ); + renderInfo(); + + expect( + screen.getAllByRole("term").map((term) => term.textContent), + ).toEqual(["Core first", "Core second"]); + 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", order: 200, 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..cb97fb86b12 --- /dev/null +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx @@ -0,0 +1,108 @@ +import type { ComponentType } from "react"; +import type { ExperimentalThreadInfoProps } from "@get-bb/plugin-sdk"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { InfoSectionCollapseIdContext } from "./useInfoSectionCollapse"; + +export interface CoreThreadInfoRegistration { + id: string; + order: number; + component: ComponentType; +} + +interface PluginThreadInfoRegistration { + id: 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) => { + const leftOrder = + left.entry.registration.order ?? Number.POSITIVE_INFINITY; + const rightOrder = + right.entry.registration.order ?? Number.POSITIVE_INFINITY; + if (leftOrder !== rightOrder) return leftOrder < rightOrder ? -1 : 1; + return left.index - 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.tsx b/apps/app/src/components/ui/detail-card.tsx index 97cb178f4b5..0c14df058a0 100644 --- a/apps/app/src/components/ui/detail-card.tsx +++ b/apps/app/src/components/ui/detail-card.tsx @@ -113,3 +113,33 @@ export function DetailRow({
); } + +interface InfoDetailRowProps { + label: ReactNode; + icon?: IconName; + align?: "start" | "center"; + children: ReactNode; +} + +export function InfoDetailRow({ + label, + icon, + align, + children, +}: InfoDetailRowProps) { + return ( + {label} + ) + } + align={align} + valueClassName="min-w-0" + > + {children} + + ); +} diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index a8a1dc49add..78238022843 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 { @@ -83,6 +87,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 { InfoDetailRow } from "@/components/ui/detail-card"; + +function PluginInfoListRow({ + onSelect, + ...props +}: ExperimentalInfoListRowProps) { + return ( + + ); +} function useArchiveEnvironmentThreads(): ( environmentId: string, @@ -124,6 +146,10 @@ export const pluginSdkAppImplementation = { experimental_useCheckoutState: usePluginCheckoutState, experimental_SourceCode: PluginSourceCode, experimental_Diff: PluginDiff, + experimental_InfoSection: InfoSection, + experimental_DetailRow: InfoDetailRow, + experimental_InfoList: InfoList, + experimental_InfoListRow: PluginInfoListRow, experimental_useSidebarThreads: useSidebarThreads, experimental_useSidebarThreadActions: useSidebarThreadActions, experimental_useThreadActions: useThreadActionEntries, 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/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..b81375c9091 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2411,6 +2411,54 @@ 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_InfoSection` / `experimental_DetailRow` / `experimental_InfoList` / `experimental_InfoListRow` (`@get-bb/plugin-sdk/app`) + +A registration is `{ id, 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 by `order`, then +registration order; unordered entries follow every ordered one. 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. Core's eleven rows draw with `InfoDetailRow` (the host side of +`experimental_DetailRow`) except Branch and Archived, which truncate their +value cell; 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. **Order numbers.** Core's orders are documented numbers, not exported + constants. Decide whether to export them, or to anchor plugins relative to + a named core entry. +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. **Row value styling.** `experimental_DetailRow` always lets the value cell + shrink without truncating; Branch and Archived use a truncating value cell. + Decide whether that variant needs a prop. +5. **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..f38308a079a 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1763,6 +1763,119 @@ 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; + /** + * Position among all rows, ascending; ties keep registration order, and + * rows without one follow every ordered row. bb's own rows use Parent 100, + * Notify parent 200, Project 300, Environment and Machine 400, environment + * provisioning failure 500, Directory 600, Branch 700, Merge base 800, + * Git status 900, Pull request 1000, and Archived 1100. + */ + 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; + /** + * Position among all sections, ascending; ties keep registration order, and + * sections without one follow every ordered section. bb's own sections use + * Child threads 100, Forks 200, Commits 300, Uncommitted files 400, and + * Thread storage 500. + */ + 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 +2754,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 @@ -4529,6 +4658,30 @@ 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..0fbaa9caea2 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", ); 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..0730a2af296 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,26 @@ 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, + ...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, + ...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 +824,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..db9ea57c19f 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, @@ -1219,6 +1226,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 +1461,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; }, @@ -1587,6 +1670,8 @@ export interface CapturedPluginApp { threadLists: PluginThreadListRegistration[]; experimentalSidebarNavigations: ExperimentalSidebarNavigationRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; + threadInfoRows: ExperimentalThreadInfoRowRegistration[]; + threadInfoSections: ExperimentalThreadInfoSectionRegistration[]; threadActions: PluginThreadActionRegistration[]; browserToolbarActions: ExperimentalPluginBrowserToolbarActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index ca68d55e94c..5ea34029c0f 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -4,9 +4,13 @@ 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, useBbNavigate, useRealtime, useRpc, + type ExperimentalThreadInfoProps, type PluginNavPanelProps, } from "@get-bb/plugin-sdk/app"; import type { automationRpcContract } from "./src/rpc.js"; @@ -16,17 +20,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 +224,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) !== null) { + 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 +879,9 @@ export default definePluginApp((app) => { component: AutomationsPanel, experimental_usePageTitle: useAutomationsPageTitle, }); + app.slots.experimental_threadInfoSection({ + id: "automations", + order: 50, + 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..fb1f9400989 --- /dev/null +++ b/plugins/automations/src/thread-info-section.test.tsx @@ -0,0 +1,97 @@ +// @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 change, 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(); + + 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..64cbc28065e 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` @@ -110,6 +114,11 @@ Read the installed SDK declarations for the exact current signatures. - `ExperimentalSidebarFooterDisclosureProps` - `PluginThreadListProps` - `PluginThreadHeaderActionProps` +- `ExperimentalThreadInfoProps` +- `ExperimentalInfoSectionProps` +- `ExperimentalDetailRowProps` +- `ExperimentalInfoListProps` +- `ExperimentalInfoListRowProps` - `ExperimentalPluginBrowserToolbarActionProps` - `ExperimentalPluginBrowserPage` - `ExperimentalPluginBrowserPageEvaluateOptions` @@ -181,6 +190,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..ebbddd27035 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,65 @@ 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, and `app.slots.experimental_threadInfoSection` adds a section below it. +bb's own rows (Project, Environment, Branch, Pull request, …) and sections +(Child threads, Forks, Commits, …) are registrations of the same shape, drawn +with the same components the SDK exports, so plugin content looks identical. + +```tsx +import { + experimental_DetailRow as DetailRow, + experimental_InfoList as InfoList, + experimental_InfoListRow as InfoListRow, + experimental_InfoSection as InfoSection, +} from "@get-bb/plugin-sdk/app"; + +app.slots.experimental_threadInfoRow({ + id: "deploy", + order: 1050, + component: ({ threadId }) => ( + {useDeploy(threadId)} + ), +}); + +app.slots.experimental_threadInfoSection({ + id: "automations", + order: 50, + component: ({ threadId }) => { + const items = useItems(threadId); + if (items.length === 0) return null; + return ( + + item.id} + limit={3} + renderItem={(item) => ( + } name={item.name} onSelect={...} /> + )} + /> + + ); + }, +}); +``` + +Both components receive `{ threadId, projectId }` and mount once per visible +thread, so a split layout mounts one copy per pane. Return null to show nothing +for a thread. `order` places the entry among bb's: rows use Parent 100, Notify +parent 200, Project 300, Environment and Machine 400, provisioning failure 500, +Directory 600, Branch 700, Merge base 800, Git status 900, Pull request 1000, +and Archived 1100; sections use Child threads 100, Forks 200, Commits 300, +Uncommitted files 400, and Thread storage 500. Ties keep registration order; +entries without an order follow every ordered one. `experimental_InfoSection` +draws the heading, count pill, and collapse control; inside a section slot it +collapses, and bb remembers that per registration. `experimental_InfoList` +shows `limit` rows (default 5) behind a "N more" toggle. A crash removes only +that row or section for that thread. + ### 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..6f943ae97b3 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..8902eef2f7a 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -350,6 +350,32 @@ 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_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"]; From 5662b57837e88a2080c8b29a0ed578e80af113d0 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 22 Oct 2026 10:46:23 -0700 Subject: [PATCH 2/5] Keep the plugin authoring registration reference within 500 lines The Info tab slot section pushed frontend-registration.md to 528 lines, over the shipped-skill reference cap. Co-Authored-By: Claude Opus 5.5 --- .../references/frontend-registration.md | 74 ++++++------------- 1 file changed, 23 insertions(+), 51 deletions(-) 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 ebbddd27035..069564c5d0d 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 @@ -172,61 +172,33 @@ surface them here instead, filtering `experimental_useSidebarThreads()` by ### Rows and sections in the thread Info tab `app.slots.experimental_threadInfoRow` adds a row to the Info tab's details -block, and `app.slots.experimental_threadInfoSection` adds a section below it. -bb's own rows (Project, Environment, Branch, Pull request, …) and sections -(Child threads, Forks, Commits, …) are registrations of the same shape, drawn -with the same components the SDK exports, so plugin content looks identical. +block; `app.slots.experimental_threadInfoSection` adds a section below it. bb's +own rows and sections are registrations of the same `{ id, order?, component }` +shape, drawn with the SDK's Info components (frontend-components.md). ```tsx -import { - experimental_DetailRow as DetailRow, - experimental_InfoList as InfoList, - experimental_InfoListRow as InfoListRow, - experimental_InfoSection as InfoSection, -} from "@get-bb/plugin-sdk/app"; - -app.slots.experimental_threadInfoRow({ - id: "deploy", - order: 1050, - component: ({ threadId }) => ( - {useDeploy(threadId)} - ), -}); - -app.slots.experimental_threadInfoSection({ - id: "automations", - order: 50, - component: ({ threadId }) => { - const items = useItems(threadId); - if (items.length === 0) return null; - return ( - - item.id} - limit={3} - renderItem={(item) => ( - } name={item.name} onSelect={...} /> - )} - /> - - ); - }, -}); +app.slots.experimental_threadInfoSection({ id: "automations", order: 50, 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={...} />} /> + + ); +} ``` -Both components receive `{ threadId, projectId }` and mount once per visible -thread, so a split layout mounts one copy per pane. Return null to show nothing -for a thread. `order` places the entry among bb's: rows use Parent 100, Notify -parent 200, Project 300, Environment and Machine 400, provisioning failure 500, -Directory 600, Branch 700, Merge base 800, Git status 900, Pull request 1000, -and Archived 1100; sections use Child threads 100, Forks 200, Commits 300, -Uncommitted files 400, and Thread storage 500. Ties keep registration order; -entries without an order follow every ordered one. `experimental_InfoSection` -draws the heading, count pill, and collapse control; inside a section slot it -collapses, and bb remembers that per registration. `experimental_InfoList` -shows `limit` rows (default 5) behind a "N more" toggle. A crash removes only -that row or section for that thread. +Components receive `{ threadId, projectId }` and mount once per visible +thread; return null to show nothing. A row renders `experimental_DetailRow`. +`order` places the entry among bb's: rows use Parent 100, Notify parent 200, +Project 300, Environment and Machine 400, provisioning failure 500, Directory +600, Branch 700, Merge base 800, Git status 900, Pull request 1000, Archived +1100; sections use Child threads 100, Forks 200, Commits 300, Uncommitted files +400, Thread storage 500. Ties keep registration order; unordered entries come +last. 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 From 6f819ca82ce0d3ea8919bd348e7f05b20e017db2 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 22 Oct 2026 11:15:50 -0700 Subject: [PATCH 3/5] Make DetailRow the only label/value row DetailRow and InfoDetailRow were two row components: the Info tab, plugins and most rows used InfoDetailRow, while Branch, Archived and the commit dialog reached past it to style the value cell themselves. DetailRow is now the single row ({ label, icon?, align?, children }) that core and experimental_DetailRow share. Branch and Archived truncate through an inner span, and the commit dialog's vertical changed-files row draws its own markup with the shared label and value classes. Co-Authored-By: Claude Opus 5.5 --- .../dialogs/ThreadGitActionDialog.tsx | 24 +---- .../secondary-panel/ThreadMetadataContent.tsx | 102 +++++++++--------- .../info/ThreadInfoSlots.test.tsx | 24 +++-- .../src/components/ui/detail-card.stories.tsx | 18 ++-- apps/app/src/components/ui/detail-card.tsx | 69 +++--------- .../workspace/ChangedFilesDetailRow.tsx | 30 +++--- apps/app/src/lib/plugin-sdk-app-impl.tsx | 4 +- docs/api_to_audit.md | 12 +-- 8 files changed, 113 insertions(+), 170 deletions(-) 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" - > +
    + {parentThreadId ? (
    )} - + ); } @@ -238,7 +236,7 @@ export function ParentNotificationsRow({ }); if (thread.parentThreadId === null) return null; return ( - + - + ); } @@ -310,7 +308,7 @@ export function EnvironmentRow({ infoDisplay.machineName !== null ? environmentDisplayHost.identity : null; return ( <> - Environment ) : ( - - Environment - + "Environment" ) } + icon={ + providerLookup.status === "loaded" && providerLookup.provider !== null + ? undefined + : infoDisplay.icon + } > @@ -351,9 +352,9 @@ export function EnvironmentRow({ ) : null} - + {machineIdentity ? ( - (offline) )} - + ) : null} ); @@ -392,11 +393,11 @@ export function ProjectRow({ projectId }: { projectId: string }) { if (!projectName) return null; return ( - + {projectName} - + ); } @@ -407,14 +408,14 @@ export function EnvironmentProvisioningFailureRow({ }) { if (!failed) return null; return ( - + Not created · provisioning failed - + ); } @@ -446,7 +447,7 @@ function WorkspacePathValue({ path }: { path: string }) { ); return ( - +
    {fitted}
    -
    + ); } @@ -472,26 +473,23 @@ export function BranchRow({ workspaceStatus }: BranchRowProps) { : null; if (checkoutDisplay === null) return null; return ( - - {checkoutDisplay.rowLabel} - - } - valueClassName="min-w-0 truncate" - > + {checkoutDisplay.copyValue !== null ? ( - - {checkoutDisplay.label} - + + + {checkoutDisplay.label} + + ) : ( {checkoutDisplay.label} @@ -511,7 +509,7 @@ export function PullRequestRow({ pullRequest }: PullRequestRowProps) { const stateDisplay = getPullRequestStateDisplay(pullRequest); const nextStep = getPullRequestNextStep(pullRequest); return ( - +
    ) : null} - + ); } @@ -668,7 +666,7 @@ export function MergeBaseRow({ ); return ( - + {canSelectMergeBase && mergeBaseBranch ? ( {mergeBaseBranch} )} - + ); } @@ -727,7 +725,7 @@ export function GitStatusRow({ }).summary.replace(/\.$/, "")}. Showing the last known status.` : null; return ( - + {formatBranchComparison({ @@ -756,7 +754,7 @@ export function GitStatusRow({ ) : null} {!mergeBase.squashMergeCheckComplete ? : null} - + ); } @@ -766,7 +764,7 @@ export function GitStatusRow({ workspaceDeleted: environment?.status === "destroyed", }); return ( - + - + ); } @@ -797,11 +795,13 @@ export function ArchivedRow({ thread }: ArchivedRowProps) { }, [thread.id, unarchiveThread]); if (thread.archivedAt == null) return null; return ( - Archived} - valueClassName="min-w-0 truncate" - > - + + + + ); } diff --git a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx index 0784dd5fbe6..8343bd7b6e3 100644 --- a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx @@ -5,7 +5,7 @@ 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 { InfoDetailRow } from "@/components/ui/detail-card"; +import { DetailRow } from "@/components/ui/detail-card"; import { removePluginSlotRegistrations, setPluginSlotRegistrations, @@ -21,9 +21,9 @@ import { function row(label: string) { return function Row({ threadId }: ExperimentalThreadInfoProps) { return ( - + {label} for {threadId} - + ); }; } @@ -56,7 +56,11 @@ function renderInfo() {
    - + { ); renderInfo(); - expect( - screen.getAllByRole("term").map((term) => term.textContent), - ).toEqual(["Core first", "Plugin between", "Core second", "Plugin unordered"]); + expect(screen.getAllByRole("term").map((term) => term.textContent)).toEqual( + ["Core first", "Plugin between", "Core second", "Plugin unordered"], + ); expect( screen.getAllByRole("heading").map((heading) => heading.textContent), ).toEqual(["Plugin early1", "Core section1", "Plugin late1"]); @@ -112,9 +116,9 @@ describe("thread Info slots", () => { ); renderInfo(); - expect( - screen.getAllByRole("term").map((term) => term.textContent), - ).toEqual(["Core first", "Core second"]); + expect(screen.getAllByRole("term").map((term) => term.textContent)).toEqual( + ["Core first", "Core second"], + ); expect(screen.getByText("Core section body")).toBeTruthy(); expect(screen.queryByText(/demo/)).toBeNull(); }); 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}
    -
    - ); -} - -interface InfoDetailRowProps { - label: ReactNode; - icon?: IconName; - align?: "start" | "center"; - children: ReactNode; -} - -export function InfoDetailRow({ - label, - icon, - align, - children, -}: InfoDetailRowProps) { - return ( - + {icon === undefined ? ( label ) : ( {label} - ) - } - align={align} - valueClassName="min-w-0" - > - {children} - + )} + +
    {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/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 78238022843..776dd7c4b15 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -92,7 +92,7 @@ import { InfoListRow, InfoSection, } from "@/components/secondary-panel/info/info-list"; -import { InfoDetailRow } from "@/components/ui/detail-card"; +import { DetailRow } from "@/components/ui/detail-card"; function PluginInfoListRow({ onSelect, @@ -147,7 +147,7 @@ export const pluginSdkAppImplementation = { experimental_SourceCode: PluginSourceCode, experimental_Diff: PluginDiff, experimental_InfoSection: InfoSection, - experimental_DetailRow: InfoDetailRow, + experimental_DetailRow: DetailRow, experimental_InfoList: InfoList, experimental_InfoListRow: PluginInfoListRow, experimental_useSidebarThreads: useSidebarThreads, diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index b81375c9091..e3c6ec9fc92 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2430,9 +2430,10 @@ stored in the `infoPanel.collapsedSections` preference under the core id or `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. Core's eleven rows draw with `InfoDetailRow` (the host side of -`experimental_DetailRow`) except Branch and Archived, which truncate their -value cell; core's five sections draw with `InfoSection` and `InfoList`. The +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`. @@ -2452,10 +2453,7 @@ Docs: `frontend-registration.md`, `frontend-components.md`. (`hasAnyThreadMetadata`), so plugin entries never count toward it and are hidden with core's details. Decide whether registrations should report visibility. -4. **Row value styling.** `experimental_DetailRow` always lets the value cell - shrink without truncating; Branch and Archived use a truncating value cell. - Decide whether that variant needs a prop. -5. **Separators.** The Info tab draws section separators on every +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. From a7d2ee0ea2cd3b4bcf74d8d0fbaccb21d2e48f08 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 22 Oct 2026 11:26:38 -0700 Subject: [PATCH 4/5] Place Info tab entries by group, like thread actions Info rows and sections took one flat order number, and every core row's number was documented, so renumbering core would move plugin entries. They now take a required group plus an optional order within it, sorted with the same comparator thread actions use (moved to lib/grouped-placement.ts). bb's groups ship as experimental_THREAD_INFO_ROW_GROUPS (thread, environment, git, pullRequest, lifecycle) and experimental_THREAD_INFO_SECTION_GROUPS (related, changes, storage). Core keeps today's order; Automations joins related ahead of Child threads. Co-Authored-By: Claude Opus 5.5 --- .../secondary-panel/ThreadMetadataContent.tsx | 104 +++++++++++++++--- .../info/ThreadInfoSlots.test.tsx | 59 +++++++--- .../secondary-panel/info/ThreadInfoSlots.tsx | 19 ++-- apps/app/src/lib/grouped-placement.ts | 17 +++ apps/app/src/lib/plugin-sdk-app-impl.tsx | 4 + .../thread-actions/thread-action-registry.tsx | 16 +-- docs/api_to_audit.md | 18 +-- packages/plugin-sdk/src/app-contract.ts | 51 +++++++-- packages/plugin-sdk/src/app.ts | 4 + packages/plugin-sdk/src/index.ts | 4 + .../plugin-app-registration-collector.ts | 2 + packages/plugin-sdk/src/testing/app.tsx | 6 + packages/plugin-sdk/src/thread-info-groups.ts | 15 +++ plugins/automations/app.tsx | 4 +- .../references/frontend-api-index.md | 2 + .../references/frontend-registration.md | 17 ++- .../plugin-api-docs/src/anatomy-manifest.json | 2 +- plugins/plugin-api-docs/src/surfaces.ts | 2 + 18 files changed, 266 insertions(+), 80 deletions(-) create mode 100644 apps/app/src/lib/grouped-placement.ts create mode 100644 packages/plugin-sdk/src/thread-info-groups.ts diff --git a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx index f0762ed9810..6f3c7b78ea1 100644 --- a/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx +++ b/apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx @@ -24,7 +24,11 @@ import { ThreadInfoSections, type CoreThreadInfoRegistration, } from "./info/ThreadInfoSlots"; -import type { ExperimentalThreadInfoProps } from "@get-bb/plugin-sdk"; +import { + experimental_THREAD_INFO_ROW_GROUPS, + experimental_THREAD_INFO_SECTION_GROUPS, + type ExperimentalThreadInfoProps, +} from "@get-bb/plugin-sdk"; import { Link } from "react-router-dom"; import type { Environment, @@ -1088,38 +1092,104 @@ function CoreThreadStorageSection() { 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", order: 100, component: CoreParentRow }, + { + id: "parent", + group: ROW_GROUPS.thread, + order: 10, + component: CoreParentRow, + }, { id: "parentNotifications", - order: 200, + group: ROW_GROUPS.thread, + order: 20, component: CoreParentNotificationsRow, }, - { id: "project", order: 300, component: CoreProjectRow }, - { id: "environment", order: 400, component: CoreEnvironmentRow }, + { + id: "project", + group: ROW_GROUPS.thread, + order: 30, + component: CoreProjectRow, + }, + { + id: "environment", + group: ROW_GROUPS.environment, + order: 10, + component: CoreEnvironmentRow, + }, { id: "environmentProvisioningFailure", - order: 500, + group: ROW_GROUPS.environment, + order: 20, component: CoreEnvironmentProvisioningFailureRow, }, - { id: "directory", order: 600, component: CoreWorkspacePathRow }, - { id: "branch", order: 700, component: CoreBranchRow }, - { id: "mergeBase", order: 800, component: CoreMergeBaseRow }, - { id: "gitStatus", order: 900, component: CoreGitStatusRow }, - { id: "pullRequest", order: 1000, component: CorePullRequestRow }, - { id: "archived", order: 1100, component: CoreArchivedRow }, + { + 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", order: 100, component: CoreChildThreadsSection }, - { id: "forks", order: 200, component: CoreForksSection }, - { id: "commits", order: 300, component: CoreCommitsSection }, + { + 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", - order: 400, + group: SECTION_GROUPS.changes, + order: 20, component: CoreUncommittedChangesSection, }, - { id: "threadStorage", order: 500, component: CoreThreadStorageSection }, + { + id: "threadStorage", + group: SECTION_GROUPS.storage, + order: 10, + component: CoreThreadStorageSection, + }, ]; export function ThreadMetadataContent(props: ThreadMetadataContentProps) { diff --git a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx index 8343bd7b6e3..aa6a96df465 100644 --- a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx @@ -43,12 +43,18 @@ function Crashes(): never { } const CORE_ROWS: CoreThreadInfoRegistration[] = [ - { id: "first", order: 100, component: row("Core first") }, - { id: "second", order: 200, component: row("Core second") }, + { 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", order: 100, component: section("Core section") }, + { + id: "coreSection", + group: "1_a", + order: 10, + component: section("Core section"), + }, ]; function renderInfo() { @@ -80,24 +86,44 @@ afterEach(() => { }); describe("thread Info slots", () => { - it("places plugin rows and sections among core's by order, unordered ones last", () => { + it("places plugin rows and sections by group, then order, after core on a tie", () => { setPluginSlotRegistrations( "demo", makePluginRegistrationSet({ threadInfoRows: [ - { id: "unordered", component: row("Plugin unordered") }, - { id: "between", order: 150, component: row("Plugin between") }, + { 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", component: section("Plugin late") }, - { id: "early", order: 50, component: section("Plugin early") }, + { 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 unordered"], + [ + "Core first", + "Plugin between", + "Core second", + "Plugin tie", + "Plugin unordered", + "Plugin own group", + "Core later", + ], ); expect( screen.getAllByRole("heading").map((heading) => heading.textContent), @@ -110,14 +136,16 @@ describe("thread Info slots", () => { setPluginSlotRegistrations( "demo", makePluginRegistrationSet({ - threadInfoRows: [{ id: "broken", order: 150, component: Crashes }], - threadInfoSections: [{ id: "broken", order: 50, component: Crashes }], + 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 first", "Core second", "Core later"], ); expect(screen.getByText("Core section body")).toBeTruthy(); expect(screen.queryByText(/demo/)).toBeNull(); @@ -128,7 +156,12 @@ describe("thread Info slots", () => { "demo", makePluginRegistrationSet({ threadInfoSections: [ - { id: "coreSection", order: 200, component: section("Plugin") }, + { + id: "coreSection", + group: "1_a", + order: 20, + component: section("Plugin"), + }, ], }), ); diff --git a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx index cb97fb86b12..d49a848778f 100644 --- a/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx +++ b/apps/app/src/components/secondary-panel/info/ThreadInfoSlots.tsx @@ -1,17 +1,20 @@ 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; @@ -35,14 +38,14 @@ function sortThreadInfoEntries( ]; return entries .map((entry, index) => ({ entry, index })) - .sort((left, right) => { - const leftOrder = - left.entry.registration.order ?? Number.POSITIVE_INFINITY; - const rightOrder = - right.entry.registration.order ?? Number.POSITIVE_INFINITY; - if (leftOrder !== rightOrder) return leftOrder < rightOrder ? -1 : 1; - return left.index - right.index; - }) + .sort((left, right) => + compareGroupedPlacement( + left.entry.registration, + left.index, + right.entry.registration, + right.index, + ), + ) .map(({ entry }) => entry); } 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 776dd7c4b15..613a5f3a36e 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -69,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, @@ -159,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/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/docs/api_to_audit.md b/docs/api_to_audit.md index e3c6ec9fc92..26d4d4693b3 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -2411,12 +2411,15 @@ 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_InfoSection` / `experimental_DetailRow` / `experimental_InfoList` / `experimental_InfoListRow` (`@get-bb/plugin-sdk/app`) +## `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, order?, component }`. The component receives +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 by `order`, then -registration order; unordered entries follow every ordered one. Each plugin +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), @@ -2445,9 +2448,10 @@ Docs: `frontend-registration.md`, `frontend-components.md`. 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. **Order numbers.** Core's orders are documented numbers, not exported - constants. Decide whether to export them, or to anchor plugins relative to - a named core entry. +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 diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index f38308a079a..5860ce817dd 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1786,12 +1786,14 @@ export interface ExperimentalThreadInfoRowRegistration { /** Unique within the plugin; letters, digits, `-`, `_`. */ id: string; /** - * Position among all rows, ascending; ties keep registration order, and - * rows without one follow every ordered row. bb's own rows use Parent 100, - * Notify parent 200, Project 300, Environment and Machine 400, environment - * provisioning failure 500, Directory 600, Branch 700, Merge base 800, - * Git status 900, Pull request 1000, and Archived 1100. + * 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; } @@ -1806,11 +1808,13 @@ export interface ExperimentalThreadInfoSectionRegistration { /** Unique within the plugin; letters, digits, `-`, `_`. */ id: string; /** - * Position among all sections, ascending; ties keep registration order, and - * sections without one follow every ordered section. bb's own sections use - * Child threads 100, Forks 200, Commits 300, Uncommitted files 400, and - * Thread storage 500. + * 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; } @@ -4404,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}). @@ -4675,7 +4704,9 @@ export interface PluginSdkApp { * 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; + experimental_InfoList( + props: ExperimentalInfoListProps, + ): ReactNode; /** * One row of `experimental_InfoList` (see * {@link ExperimentalInfoListRowProps}), the row bb's own sections use. diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index 0fbaa9caea2..9373c704a67 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -222,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 0730a2af296..3da3de878a1 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-registration-collector.ts @@ -797,6 +797,7 @@ export function collectPluginAppRegistrations( requireUniqueId(kind, seenIds.threadInfoRow, id); collected.threadInfoRows.push({ id, + group: requireNonEmptyString(kind, "group", registration.group), ...requireOptionalOrder(kind, registration.order), component: requireComponent(kind, registration.component), }); @@ -807,6 +808,7 @@ export function collectPluginAppRegistrations( requireUniqueId(kind, seenIds.threadInfoSection, id); collected.threadInfoSections.push({ id, + group: requireNonEmptyString(kind, "group", registration.group), ...requireOptionalOrder(kind, registration.order), component: requireComponent(kind, registration.component), }); diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index db9ea57c19f..087cc5baa2b 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -139,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, @@ -1516,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; }, 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 5ea34029c0f..b774bb9f9ac 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -7,6 +7,7 @@ import { experimental_InfoList as InfoList, experimental_InfoListRow as InfoListRow, experimental_InfoSection as InfoSection, + experimental_THREAD_INFO_SECTION_GROUPS, useBbNavigate, useRealtime, useRpc, @@ -881,7 +882,8 @@ export default definePluginApp((app) => { }); app.slots.experimental_threadInfoSection({ id: "automations", - order: 50, + group: experimental_THREAD_INFO_SECTION_GROUPS.related, + order: 0, component: AutomationsThreadInfoSection, }); }); 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 64cbc28065e..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 @@ -52,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 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 069564c5d0d..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 @@ -173,11 +173,11 @@ surface them here instead, filtering `experimental_useSidebarThreads()` by `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, order?, component }` -shape, drawn with the SDK's Info components (frontend-components.md). +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", order: 50, component: Automations }); +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; @@ -192,12 +192,11 @@ function Automations({ threadId }: ExperimentalThreadInfoProps) { Components receive `{ threadId, projectId }` and mount once per visible thread; return null to show nothing. A row renders `experimental_DetailRow`. -`order` places the entry among bb's: rows use Parent 100, Notify parent 200, -Project 300, Environment and Machine 400, provisioning failure 500, Directory -600, Branch 700, Merge base 800, Git status 900, Pull request 1000, Archived -1100; sections use Child threads 100, Forks 200, Commits 300, Uncommitted files -400, Thread storage 500. Ties keep registration order; unordered entries come -last. Inside a section slot `experimental_InfoSection` collapses and bb +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 diff --git a/plugins/plugin-api-docs/src/anatomy-manifest.json b/plugins/plugin-api-docs/src/anatomy-manifest.json index 6f943ae97b3..77860ac87a5 100644 --- a/plugins/plugin-api-docs/src/anatomy-manifest.json +++ b/plugins/plugin-api-docs/src/anatomy-manifest.json @@ -290,7 +290,7 @@ { "path": "apps/app/src/components/secondary-panel/info/ThreadInfoSlots.test.tsx", "anchors": [ - "places plugin rows and sections among core's by order, unordered ones last", + "places plugin rows and sections by group, then order, after core on a tie", "drops only a crashing plugin row or section", "collapses each section on its own, keyed by registration" ] diff --git a/plugins/plugin-api-docs/src/surfaces.ts b/plugins/plugin-api-docs/src/surfaces.ts index 8902eef2f7a..85629117b4a 100644 --- a/plugins/plugin-api-docs/src/surfaces.ts +++ b/plugins/plugin-api-docs/src/surfaces.ts @@ -364,6 +364,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "ExperimentalThreadInfoRowRegistration", "ExperimentalThreadInfoSectionRegistration", "ExperimentalThreadInfoProps", + "experimental_THREAD_INFO_ROW_GROUPS", + "experimental_THREAD_INFO_SECTION_GROUPS", "experimental_InfoSection", "ExperimentalInfoSectionProps", "experimental_DetailRow", From 8a8469d37180c68a1525b58b94ed711776f16889 Mon Sep 17 00:00:00 2001 From: Michael Yong Date: Thu, 22 Oct 2026 11:33:36 -0700 Subject: [PATCH 5/5] Skip run-only signals in the Automations Info section Run-only signals never change the automation rows the section shows; every write that does also publishes automations-changed. Ignoring automation-runs-changed halves the section's refetches while runs happen. Co-Authored-By: Claude Opus 5.5 --- plugins/automations/app.tsx | 2 +- plugins/automations/src/thread-info-section.test.tsx | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/automations/app.tsx b/plugins/automations/app.tsx index b774bb9f9ac..5e0e1c78d37 100644 --- a/plugins/automations/app.tsx +++ b/plugins/automations/app.tsx @@ -242,7 +242,7 @@ function useTargetThreadAutomations( automationQueryClient, ); useRealtime("automations", (payload) => { - if (asSignal(payload) !== null) { + if (asSignal(payload)?.kind === "automations-changed") { void automationQueryClient.invalidateQueries( { queryKey }, { cancelRefetch: false }, diff --git a/plugins/automations/src/thread-info-section.test.tsx b/plugins/automations/src/thread-info-section.test.tsx index fb1f9400989..61e6282b50b 100644 --- a/plugins/automations/src/thread-info-section.test.tsx +++ b/plugins/automations/src/thread-info-section.test.tsx @@ -43,7 +43,7 @@ function automation( } describe("automations thread Info section", () => { - it("lists the thread's automations, refreshes on any project's change, and opens one", async () => { + 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[] = []; @@ -60,6 +60,12 @@ describe("automations thread Info section", () => { }); 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 },