diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx index c84322fc92..edf63b36d0 100644 --- a/apps/app/src/components/layout/AppLayout.tsx +++ b/apps/app/src/components/layout/AppLayout.tsx @@ -862,6 +862,7 @@ export function AppLayout({ children }: AppLayoutProps) { hostId={quickCreateProject.hostId} hostName={quickCreateProject.hostName} hosts={quickCreateProject.hosts} + nativeFolderPicker={quickCreateProject.nativeFolderPicker} onOpenChange={quickCreateProject.projectPathDialog.onOpenChange} onSubmit={quickCreateProject.submitProjectPath} /> diff --git a/apps/app/src/components/layout/app-chrome-selection.test.tsx b/apps/app/src/components/layout/app-chrome-selection.test.tsx index 1e1f4ec2bb..c7198f6d4d 100644 --- a/apps/app/src/components/layout/app-chrome-selection.test.tsx +++ b/apps/app/src/components/layout/app-chrome-selection.test.tsx @@ -70,13 +70,8 @@ describe("app chrome opts out of text selection", () => { expect(getPanel().classList.contains("select-none")).toBe(true); }); - it("marks the right panel's top chrome with and without the diff toolbar", () => { - expect(getSecondaryPanelChromeStackClassName(false)).toContain( - "select-none", - ); - expect(getSecondaryPanelChromeStackClassName(true)).toContain( - "select-none", - ); + it("marks the right panel's top chrome", () => { + expect(getSecondaryPanelChromeStackClassName()).toContain("select-none"); }); it("restores native selection on editable controls inside opted-out chrome", () => { diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index e8d060c072..de0c8036c1 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -478,6 +478,7 @@ function PluginNavSidebarItem({ content, enabled: splitEnabled, label: chrome.title, + onNavigate, }); const splitIndicator = usePaneContentSplitIndicator(content, splitEnabled); const SidebarAccessory = panel?.experimental_sidebarAccessory; @@ -507,11 +508,11 @@ function PluginNavSidebarItem({ // sidebar, so it coexists with the dnd-kit reorder listeners. onPointerDown={onPointerDown} onSelect={(event) => { - onNavigate?.(); if (event.metaKey || event.ctrlKey) { openInSplit(); return; } + onNavigate?.(); void navigate(path); }} /> diff --git a/apps/app/src/components/plugin/PluginReplacementSlot.tsx b/apps/app/src/components/plugin/PluginReplacementSlot.tsx index 87b6676659..fa37fa8071 100644 --- a/apps/app/src/components/plugin/PluginReplacementSlot.tsx +++ b/apps/app/src/components/plugin/PluginReplacementSlot.tsx @@ -33,12 +33,14 @@ export function PluginReplacementSlot< Registration extends PluginReplacementRegistration, >({ children, + instanceId, onCrash, original, replacement, slotKind, }: { children: (registration: Registration, Original: ComponentType) => ReactNode; + instanceId?: string; onCrash?: (pluginId: string) => void; original: ReactNode; replacement: ResolvedReplacement; @@ -50,11 +52,12 @@ export function PluginReplacementSlot< return ( } + {...(instanceId === undefined ? {} : { instanceId })} {...(onCrash === undefined ? {} : { onCrash })} > {children(registration, PluginOwnerRenderer)} diff --git a/apps/app/src/components/plugin/PluginResponsiveDrawer.tsx b/apps/app/src/components/plugin/PluginResponsiveDrawer.tsx new file mode 100644 index 0000000000..3492a762c9 --- /dev/null +++ b/apps/app/src/components/plugin/PluginResponsiveDrawer.tsx @@ -0,0 +1,28 @@ +import type { ExperimentalResponsiveDrawerProps } from "@get-bb/plugin-sdk/app"; +import { ResponsiveDrawerShell } from "@bb/shared-ui/responsive-overlay"; +import { cn } from "@bb/shared-ui/lib/utils"; + +/** Host-owned persistent drawer for plugin surfaces with deferred realization. */ +export function PluginResponsiveDrawer({ + open, + onOpenChange, + title, + children, + contentClassName, +}: ExperimentalResponsiveDrawerProps) { + return ( + +
+ {children} +
+
+ ); +} diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.ts b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.ts index ed6251fd67..24a5ba15ff 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.ts +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.test.ts @@ -1,15 +1,10 @@ import { describe, expect, it } from "vitest"; import { - getSecondaryPanelChromeStackClassName, getReservedInlinePanelToggleClassName, isSecondaryPanelLayoutTransition, resolveCollapsedPanelTrafficLightReserveClassName, } from "./ThreadSecondaryPanel"; -import { - CHROME_ROW_CLASS, - CHROME_ROW_HEIGHT_CLASS, - MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS, -} from "@/lib/bb-desktop"; +import { MACOS_COLLAPSED_TOP_LEFT_RESERVE_CLASS } from "@/lib/bb-desktop"; import { SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS } from "./panelChromeClasses"; describe("secondary panel surface tone", () => { @@ -26,18 +21,6 @@ describe("secondary panel native browser bounds settling", () => { }); }); -describe("getSecondaryPanelChromeStackClassName", () => { - it("reserves the combined navigation and active Diff toolbar height", () => { - const className = getSecondaryPanelChromeStackClassName(true); - - expect(className).toContain("flex"); - expect(className).toContain("flex-col"); - expect(className).toContain("shrink-0"); - expect(className).not.toContain(CHROME_ROW_HEIGHT_CLASS); - expect(CHROME_ROW_CLASS).toContain(CHROME_ROW_HEIGHT_CLASS); - }); -}); - // The reserved inline-toggle slot sits under root compose's pinned right-panel // toggle. On macOS desktop the top chrome is an [app-region:drag] window-drag // region; Electron resolves draggable regions in DOM order (later wins), so the diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index ae071bda39..04d9817e99 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -12,7 +12,7 @@ import { useState, } from "react"; import { useAtomValue } from "jotai"; -import type { DiffFileEntry } from "@bb/server-contract"; +import type { ExperimentalChangesViewTargetState } from "@get-bb/plugin-sdk"; import { Icon } from "@bb/shared-ui/icon"; import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { Panel, PanelResizeHandle } from "react-resizable-panels"; @@ -47,26 +47,13 @@ import type { SecondaryPanelRenderableTab, SecondaryPanelTabReorderHandler, } from "./secondaryPanelTab"; -import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries"; -import { - DEFAULT_CODE_OVERFLOW_MODE, - type CodeOverflowMode, -} from "@/lib/code-overflow-mode"; -import type { DiffPresentation } from "@/components/code/code-rendering"; -import { useGitDiffPanelState } from "./git-diff/useGitDiffPanelState"; import { useResponsiveGitDiffPanelDisplay } from "./git-diff/useResponsiveGitDiffPanelDisplay"; -import { - summarizeDiffFileEntries, - useDiffFilesCollapseControls, -} from "./git-diff/diffFilesStore"; -import { buildGitDiffIdentity } from "./git-diff/gitDiffPanelHelpers"; +import { ChangesViewHost } from "./git-diff/ChangesViewHost"; import { type SecondaryPanelDraggingHandler, useSecondaryPanelResize, } from "./useSecondaryPanelResize"; import { threadSecondaryPanelResizingAtom } from "./threadSecondaryPanelAtoms"; -import { GitDiffToolbar } from "./GitDiffToolbar"; -import { GitDiffTabContent } from "./ThreadSecondaryPanelTabContent"; import { CHROME_ROW_CLASS, getBbDesktopInfo, @@ -119,10 +106,6 @@ const SECONDARY_RESIZABLE_PANEL_STYLE: CSSProperties = { }; const SECONDARY_PANEL_CHROME_ICON_BUTTON_CLASS = `${COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS} shrink-0 ${CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS}`; const SECONDARY_PANEL_HIDE_ICON_BUTTON_CLASS = `${COARSE_POINTER_HEADER_ICON_BUTTON_CLASS} shrink-0 ${CHROME_SUBTLE_ICON_BUTTON_FOREGROUND_CLASS}`; -// Stable empty TOC reference so the collapse-controls hook's derived atom and -// the stats memo are not rebuilt every render while the diff is loading/absent. -const EMPTY_DIFF_FILES: readonly DiffFileEntry[] = []; - // The reserved slot occupies the exact footprint of root compose's pinned // right-panel toggle (which is painted on top of this slot). On macOS desktop // the top chrome is a window-drag region ([app-region:drag]); Electron resolves @@ -141,17 +124,9 @@ export function getReservedInlinePanelToggleClassName( ); } -/** - * Keeps the navigation row and optional Diff toolbar in normal document flow. - * The stack must reserve the combined height of both rows before the flexible - * panel body begins; only the navigation row owns the fixed chrome-row height. - */ -export function getSecondaryPanelChromeStackClassName( - hasGitDiffToolbar: boolean, -): string { +export function getSecondaryPanelChromeStackClassName(): string { return cn( "shrink-0 select-none", - hasGitDiffToolbar && "flex flex-col", SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS, ); } @@ -228,6 +203,8 @@ export interface ThreadSecondaryPanelProps { onRetryGitDiffEligibility?: () => void; requestedMergeBaseBranch?: string; environmentId?: string; + threadId?: string; + changesViewInstanceId?: string; metadataContent: ReactNode; tabs: readonly SecondaryPanelRenderableTab[]; fixedTabs: readonly SecondaryPanelFixedTab[]; @@ -271,10 +248,8 @@ export interface ThreadSecondaryPanelProps { onPanelFocus: () => void; onCollapse: () => void; onClose: () => void; - onClearPendingGitDiffIntent?: () => void; onOpenNewTab: () => void; - pendingGitDiffCommitSha?: string | null; - pendingGitDiffScrollPath?: string | null; + pendingGitDiffTarget?: ExperimentalChangesViewTargetState | null; workspaceRootPath?: string | null; onOpenFileInEditor?: (path: string) => void; onOpenFilePreview?: (path: string) => void; @@ -306,6 +281,8 @@ export function ThreadSecondaryPanel({ gitDiffTabStatus, requestedMergeBaseBranch, environmentId, + threadId, + changesViewInstanceId, metadataContent, tabs, fixedTabs, @@ -320,11 +297,9 @@ export function ThreadSecondaryPanel({ onPanelFocus, onCollapse, onClose, - onClearPendingGitDiffIntent, onOpenNewTab, onRetryGitDiffEligibility, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, + pendingGitDiffTarget = null, workspaceRootPath, onOpenFileInEditor, onOpenFilePreview, @@ -440,10 +415,6 @@ export function ThreadSecondaryPanel({ const activeFixedTab = fixedTabs.find((fixedTab) => fixedTab.tab.id === activeTab?.id) ?? (!hasActiveRenderableTab ? fixedTabs[0] : undefined); - const isDiffPanelActive = - resolvedGitDiffTabStatus === "eligible" && - activeFixedTab?.tab.kind === "git-diff"; - const isDiffPanelLive = isDiffPanelActive && isLayoutOpen; const isDiffEligibilityPending = activeFixedTab?.tab.kind === "git-diff" && (resolvedGitDiffTabStatus === "loading" || @@ -452,66 +423,10 @@ export function ThreadSecondaryPanel({ // first full panel mount, then retain it inside their persistent drawer. // Removing only this subtree would lose terminal and plugin state and move // the later mount cost back into the next open action. - const { - gitDiffTarget, - gitDiffSelectOptions, - gitDiffSelectValue, - onGitDiffSelectionChange, - } = useGitDiffPanelState({ - environmentId, - isDiffPanelActive: isDiffPanelLive, - requestedMergeBaseBranch, - onClearPendingGitDiffIntent, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, - }); - // Share the diff tab's table of contents with the body: React Query dedupes - // this against GitDiffTabContent's own fetch (same key), so the toolbar reads - // the file list, stats, and merge-base ref without a second round-trip. The - // toolbar's stats + collapse-all derive from this TOC, not the (removed) - // whole-diff blob. - const { data: diffFilesResponse, isLoading: isDiffFilesLoading } = - useEnvironmentDiffFiles(environmentId ?? "", { - enabled: - isDiffPanelLive && - Boolean(environmentId) && - gitDiffTarget !== undefined, - target: gitDiffTarget, - }); - const diffFiles = useMemo( - () => - diffFilesResponse?.outcome === "available" - ? diffFilesResponse.files - : EMPTY_DIFF_FILES, - [diffFilesResponse], - ); - const diffMergeBaseRef = - diffFilesResponse?.outcome === "available" - ? diffFilesResponse.mergeBaseRef - : null; - const isGitDiffTruncated = - diffFilesResponse?.outcome === "available" && diffFilesResponse.truncated; - const diffIdentity = useMemo( - () => - buildGitDiffIdentity({ - environmentId, - mergeBaseRef: diffMergeBaseRef, - target: gitDiffTarget, - }), - [diffMergeBaseRef, environmentId, gitDiffTarget], - ); - const gitDiffStats = useMemo( - () => summarizeDiffFileEntries(diffFiles), - [diffFiles], - ); - const { areAllCollapsed, toggleAllCollapsed, hasFiles } = - useDiffFilesCollapseControls(diffIdentity, diffFiles); const isSecondaryPanelResizing = useAtomValue( threadSecondaryPanelResizingAtom, ); const [desktopInfo] = useState(getBbDesktopInfo); - const [gitDiffLineOverflowMode, setGitDiffLineOverflowMode] = - useState(DEFAULT_CODE_OVERFLOW_MODE); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); const desktopWindowState = useDesktopWindowState(); const isSidebarShowing = useOptionalIsSidebarShowing(); @@ -529,14 +444,6 @@ export function ThreadSecondaryPanel({ windowState: desktopWindowState, }), }); - const gitDiffPresentation = useMemo( - () => ({ - view: gitDiffDisplayMode, - overflow: gitDiffLineOverflowMode, - showLineNumbers: true, - }), - [gitDiffDisplayMode, gitDiffLineOverflowMode], - ); const handlePanelFocusCapture = (event: FocusEvent) => { const previousTarget = event.relatedTarget; if ( @@ -769,17 +676,12 @@ export function ThreadSecondaryPanel({ const isSurfaceDiffActive = activeSurfaceFixedTab?.tab.kind === "git-diff" && resolvedGitDiffTabStatus === "eligible"; - const showsSurfaceDiffToolbar = isSurfaceDiffActive && !hasActiveSurfaceTab; const isSurfaceTerminalActive = activeSurfaceModel?.kind === "terminal" && hasActiveSurfaceTab; return ( <> -
+
) : null}
- {showsSurfaceDiffToolbar ? ( - - ) : null}
{browserSurface} @@ -909,17 +792,18 @@ export function ThreadSecondaryPanel({ )} ) : isSurfaceDiffActive ? ( - ) : activeSurfaceFixedTab?.tab.kind === "thread-info" ? ( diff --git a/apps/app/src/components/secondary-panel/git-diff/ChangesView.tsx b/apps/app/src/components/secondary-panel/git-diff/ChangesView.tsx new file mode 100644 index 0000000000..38e9602304 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/ChangesView.tsx @@ -0,0 +1,162 @@ +import { useMemo, useState } from "react"; +import type { ExperimentalChangesViewTargetState } from "@get-bb/plugin-sdk"; +import type { DiffFileEntry } from "@bb/server-contract"; +import { + DEFAULT_CODE_OVERFLOW_MODE, + type CodeOverflowMode, +} from "@/lib/code-overflow-mode"; +import type { DiffPresentation } from "@/components/code/code-rendering"; +import { useEnvironmentDiffFiles } from "@/hooks/queries/environment-queries"; +import { SECONDARY_PANEL_TOP_CHROME_BACKGROUND_CLASS } from "../panelChromeClasses"; +import { + GitDiffToolbar, + type GitDiffDisplayMode, + type GitDiffDisplayModeChangeHandler, +} from "../GitDiffToolbar"; +import { GitDiffTabContent } from "../ThreadSecondaryPanelTabContent"; +import { + summarizeDiffFileEntries, + useDiffFilesCollapseControls, +} from "./diffFilesStore"; +import { buildGitDiffIdentity } from "./gitDiffPanelHelpers"; +import { useGitDiffPanelState } from "./useGitDiffPanelState"; + +const EMPTY_DIFF_FILES: readonly DiffFileEntry[] = []; + +/** + * The wrap/scroll choice for diff lines. The view unmounts with its tab, so + * the choice is kept here: it is how the user reads diffs, not a property of + * one visit to the tab. + */ +let rememberedLineOverflowMode: CodeOverflowMode = DEFAULT_CODE_OVERFLOW_MODE; + +export interface ChangesViewProps { + displayMode: GitDiffDisplayMode; + environmentId?: string; + experimental_target: ExperimentalChangesViewTargetState | null; + isPanelOpen: boolean; + onDisplayModeChange: GitDiffDisplayModeChangeHandler; + onOpenFileInEditor?: (path: string) => void; + onOpenFilePreview?: (path: string) => void; + onSelectionAddToChat?: (text: string) => void; + requestedMergeBaseBranch?: string; + workspaceRootPath?: string | null; +} + +/** BB's complete native Changes toolbar and virtualized file body. */ +export function ChangesView({ + displayMode, + environmentId, + experimental_target, + isPanelOpen, + onDisplayModeChange, + onOpenFileInEditor, + onOpenFilePreview, + onSelectionAddToChat, + requestedMergeBaseBranch, + workspaceRootPath, +}: ChangesViewProps) { + const { + gitDiffTarget, + gitDiffSelectOptions, + gitDiffSelectValue, + onGitDiffSelectionChange, + } = useGitDiffPanelState({ + environmentId, + isDiffPanelActive: isPanelOpen, + requestedMergeBaseBranch, + experimental_target, + }); + const { data: diffFilesResponse, isLoading: isDiffFilesLoading } = + useEnvironmentDiffFiles(environmentId ?? "", { + enabled: + isPanelOpen && Boolean(environmentId) && gitDiffTarget !== undefined, + target: gitDiffTarget, + }); + const diffFiles = useMemo( + () => + diffFilesResponse?.outcome === "available" + ? diffFilesResponse.files + : EMPTY_DIFF_FILES, + [diffFilesResponse], + ); + const diffMergeBaseRef = + diffFilesResponse?.outcome === "available" + ? diffFilesResponse.mergeBaseRef + : null; + const isGitDiffTruncated = + diffFilesResponse?.outcome === "available" && diffFilesResponse.truncated; + const diffIdentity = useMemo( + () => + buildGitDiffIdentity({ + environmentId, + mergeBaseRef: diffMergeBaseRef, + target: gitDiffTarget, + }), + [diffMergeBaseRef, environmentId, gitDiffTarget], + ); + const gitDiffStats = useMemo( + () => summarizeDiffFileEntries(diffFiles), + [diffFiles], + ); + const { areAllCollapsed, toggleAllCollapsed, hasFiles } = + useDiffFilesCollapseControls(diffIdentity, diffFiles); + const [lineOverflowMode, setLineOverflowModeState] = useState( + rememberedLineOverflowMode, + ); + const setLineOverflowMode = (mode: CodeOverflowMode): void => { + rememberedLineOverflowMode = mode; + setLineOverflowModeState(mode); + }; + const presentation = useMemo( + () => ({ + view: displayMode, + overflow: lineOverflowMode, + showLineNumbers: true, + }), + [displayMode, lineOverflowMode], + ); + + return ( + <> +
+ +
+
+ +
+ + ); +} diff --git a/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.test.tsx b/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.test.tsx new file mode 100644 index 0000000000..7b92779f12 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.test.tsx @@ -0,0 +1,311 @@ +// @vitest-environment jsdom + +import { Provider as JotaiProvider, createStore } from "jotai"; +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ReactNode } from "react"; +import type { + ExperimentalChangesViewProps, + ExperimentalChangesViewTargetState, +} from "@get-bb/plugin-sdk"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, + type PluginRegistrationSet, +} from "@/lib/plugin-slots"; +import { replacementProviderKey } from "@/lib/plugin-replacement-preference"; +import { resetAllCrashedPluginSlotsForTest } from "@/components/plugin/PluginSlotMount"; +import { changesViewProviderAtom } from "./changesViewProvider"; +import { ChangesViewHost } from "./ChangesViewHost"; + +const ownerFixture = vi.hoisted(() => ({ renderDiff: false })); + +vi.mock("@/hooks/queries/environment-queries", () => { + const diffFilesResult = { + data: { + outcome: "available", + files: [ + { + path: "src/demo.ts", + previousPath: null, + changeKind: "modified", + additions: 1, + deletions: 1, + binary: false, + origin: "tracked", + loadMode: "auto", + }, + ], + initialPatches: {}, + mergeBaseRef: "main", + truncated: false, + }, + dataUpdatedAt: 1, + error: null, + isLoading: false, + isPlaceholderData: false, + }; + const workStatusResult = { + data: { + outcome: "available", + workspace: { + mergeBase: { commits: [] }, + workingTree: { files: [] }, + }, + }, + }; + return { + useEnvironmentDiffFiles: () => diffFilesResult, + useEnvironmentWorkStatus: () => workStatusResult, + }; +}); + +vi.mock("./useDiffFileContentsRequester", () => ({ + useDiffFileContentsRequester: () => vi.fn(), +})); + +vi.mock("./DiffFilesPanel", async () => { + const { PluginDiff } = await import("@/components/plugin/PluginDiff"); + return { + DiffFilesPanel: () => ( +
+ Native virtualized Changes body + {ownerFixture.renderDiff ? ( + + ) : null} +
+ ), + }; +}); + +function registrationSet( + overrides: Partial = {}, +): PluginRegistrationSet { + return { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + composerCustomizations: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + ...overrides, + }; +} + +function target( + sequence: number, + value: ExperimentalChangesViewTargetState["target"], +): ExperimentalChangesViewTargetState { + return { sequence, target: value, clear: vi.fn() }; +} + +function renderHost({ + experimental_target = null, + instanceId = "pane-a", + store = createStore(), + threadId = "thread-a", +}: { + experimental_target?: ExperimentalChangesViewTargetState | null; + instanceId?: string; + store?: ReturnType; + threadId?: string; +} = {}) { + return render( + + undefined} + requestedMergeBaseBranch="main" + threadId={threadId} + /> + , + ); +} + +function registerChangesView( + pluginId: string, + component: (props: ExperimentalChangesViewProps) => ReactNode, +) { + setPluginSlotRegistrations( + pluginId, + registrationSet({ + experimentalChangesViews: [ + { + id: "changes", + title: `${pluginId} Changes`, + component, + }, + ], + }), + ); +} + +afterEach(() => { + cleanup(); + ownerFixture.renderDiff = false; + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + window.localStorage.clear(); + vi.restoreAllMocks(); +}); + +describe("ChangesViewHost", () => { + it("uses deterministic automatic precedence and honors a named provider pin", () => { + registerChangesView("zeta", () =>
Zeta Changes
); + registerChangesView("alpha", () =>
Alpha Changes
); + + const automatic = renderHost(); + expect(screen.getByText("Alpha Changes")).toBeDefined(); + automatic.unmount(); + + const store = createStore(); + store.set( + changesViewProviderAtom, + replacementProviderKey({ pluginId: "zeta", id: "changes" }), + ); + renderHost({ store }); + expect(screen.getByText("Zeta Changes")).toBeDefined(); + }); + + it("renders experimental_Original once without re-entering replacement resolution", () => { + let replacementRenders = 0; + registerChangesView("alpha", ({ experimental_Original: Original }) => { + replacementRenders += 1; + return ; + }); + + renderHost(); + + const [toolbar] = screen.getAllByTestId("git-diff-toolbar-layout"); + expect(toolbar?.parentElement?.parentElement?.classList).toContain( + "select-none", + ); + expect(screen.getAllByTestId("bb-changes-owner")).toHaveLength(1); + expect(replacementRenders).toBe(1); + }); + + it("delivers file and commit targets independently to two pane instances", () => { + registerChangesView("alpha", ({ experimental_target, threadId }) => ( +
+ {threadId}: + {experimental_target === null + ? "none" + : experimental_target.target.kind === "file" + ? experimental_target.target.path + : experimental_target.target.sha} +
+ )); + + render( + + undefined} + requestedMergeBaseBranch="main" + threadId="thread-left" + /> + undefined} + requestedMergeBaseBranch="main" + threadId="thread-right" + /> + , + ); + + expect(screen.getByText("thread-left:left.ts")).toBeDefined(); + expect(screen.getByText("thread-right:right-sha")).toBeDefined(); + }); + + it("falls back only the crashing pane instance", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerChangesView("alpha", ({ threadId }) => { + if (threadId === "thread-left") throw new Error("left failed"); + return
Plugin {threadId}
; + }); + + render( + + undefined} + requestedMergeBaseBranch="main" + threadId="thread-left" + /> + undefined} + requestedMergeBaseBranch="main" + threadId="thread-right" + /> + , + ); + + expect(screen.getByTestId("git-diff-toolbar-layout")).toBeDefined(); + expect(screen.getByTestId("bb-changes-owner")).toBeDefined(); + expect(screen.getByText("Plugin thread-right")).toBeDefined(); + }); + + it("keeps the global diff renderer active inside experimental_Original", () => { + ownerFixture.renderDiff = true; + registerChangesView("alpha", ({ experimental_Original: Original }) => ( + + )); + setPluginSlotRegistrations( + "diff-theme", + registrationSet({ + diffRenderers: [ + { + id: "global-diff", + title: "Global diff", + component: ({ path }) =>
Global diff for {path}
, + }, + ], + }), + ); + + renderHost(); + + expect(screen.getByTestId("bb-changes-owner")).toBeDefined(); + expect(screen.getByText("Global diff for src/demo.ts")).toBeDefined(); + }); +}); diff --git a/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.tsx b/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.tsx new file mode 100644 index 0000000000..01fc2555ec --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/ChangesViewHost.tsx @@ -0,0 +1,59 @@ +import type { ExperimentalChangesViewTargetState } from "@get-bb/plugin-sdk"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { ChangesView, type ChangesViewProps } from "./ChangesView"; +import { useChangesViewReplacement } from "./changesViewProvider"; + +const CHANGES_VIEW_SLOT_KIND = "changesView"; + +interface ChangesViewHostProps extends Omit< + ChangesViewProps, + "experimental_target" +> { + experimental_target: ExperimentalChangesViewTargetState | null; + instanceId?: string; + threadId?: string; +} + +/** Resolves the exclusive whole-Changes replacement for one app pane. */ +export function ChangesViewHost({ + environmentId, + experimental_target, + instanceId, + threadId, + ...ownerProps +}: ChangesViewHostProps) { + const replacement = useChangesViewReplacement(); + const original = ( + + ); + + if ( + environmentId === undefined || + instanceId === undefined || + threadId === undefined + ) { + return original; + } + + return ( + + {(slot, BoundOriginal) => ( + + )} + + ); +} diff --git a/apps/app/src/components/secondary-panel/git-diff/changesViewProvider.ts b/apps/app/src/components/secondary-panel/git-diff/changesViewProvider.ts new file mode 100644 index 0000000000..efa4a311f3 --- /dev/null +++ b/apps/app/src/components/secondary-panel/git-diff/changesViewProvider.ts @@ -0,0 +1,24 @@ +import { useAtomValue } from "jotai"; +import { + createReplacementPreferenceAtom, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; +import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; +import { + usePluginSlots, + type ExperimentalChangesViewSlot, +} from "@/lib/plugin-slots"; + +const CHANGES_VIEW_STORAGE_KEY = "bb.appearance.changesView"; + +/** Automatic by default, with an independent per-client Appearance pin. */ +export const changesViewProviderAtom = createReplacementPreferenceAtom( + CHANGES_VIEW_STORAGE_KEY, +); + +/** The active whole-Changes replacement, or BB's view when none applies. */ +export function useChangesViewReplacement(): ResolvedReplacement { + const { experimentalChangesViews } = usePluginSlots(); + const preference = useAtomValue(changesViewProviderAtom); + return resolvePreferredReplacement(experimentalChangesViews, preference); +} diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts index cb4ccc97cc..1f90c8706b 100644 --- a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.test.ts @@ -3,8 +3,53 @@ import { openAppFixedTabFromDestinations } from "@/lib/app-fixed-tab-navigation" import { createGitDiffFixedTabDestination, GIT_DIFF_FIXED_TAB_REFERENCE, + handleGitDiffShortcut, } from "./git-diff-fixed-tab-navigation"; +describe("handleGitDiffShortcut", () => { + it("keeps the fixed Changes shortcut active around replacement rendering", () => { + const close = vi.fn(); + const open = vi.fn(); + + expect( + handleGitDiffShortcut({ + close, + eligible: true, + isActive: true, + isFocused: true, + isOpen: true, + open, + }), + ).toBe(true); + expect(close).toHaveBeenCalledOnce(); + expect(open).not.toHaveBeenCalled(); + + close.mockClear(); + expect( + handleGitDiffShortcut({ + close, + eligible: true, + isActive: false, + isFocused: true, + isOpen: true, + open, + }), + ).toBe(true); + expect(open).toHaveBeenCalledOnce(); + + expect( + handleGitDiffShortcut({ + close, + eligible: true, + isActive: false, + isFocused: false, + isOpen: false, + open, + }), + ).toBe(false); + }); +}); + describe("createGitDiffFixedTabDestination", () => { it("routes core Changes targets through the generic controller while the owner validates them", () => { const openCommit = vi.fn(); diff --git a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts index 2b1432421c..01b8c4f88f 100644 --- a/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts +++ b/apps/app/src/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation.ts @@ -11,6 +11,28 @@ export const GIT_DIFF_FIXED_TAB_REFERENCE: AppFixedTabReference = { tabId: "changes", }; +/** Handle the Changes keyboard shortcut without coupling it to view rendering. */ +export function handleGitDiffShortcut({ + close, + eligible, + isActive, + isFocused, + isOpen, + open, +}: { + close: () => void; + eligible: boolean; + isActive: boolean; + isFocused: boolean; + isOpen: boolean; + open: () => void; +}): boolean { + if (!isFocused || !eligible) return false; + if (isOpen && isActive) close(); + else open(); + return true; +} + function normalizeGitDiffFixedTabTarget( value: JsonValue, ): GitDiffFixedTabTarget | null { diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx index 4df2cd8de5..4a3d7c34ac 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.test.tsx @@ -109,9 +109,7 @@ function useMergeBaseOwner(environment: Environment, threadId: string) { const diffState = useGitDiffPanelState({ environmentId: environment.id, isDiffPanelActive: true, - onClearPendingGitDiffIntent: panel.clearPendingGitDiffIntent, - pendingGitDiffCommitSha: panel.pendingGitDiffCommitSha, - pendingGitDiffScrollPath: panel.pendingGitDiffScrollPath, + experimental_target: panel.pendingGitDiffTarget, requestedMergeBaseBranch: effectiveMergeBaseBranch, }); return { ...diffState, ...panel, effectiveMergeBaseBranch }; @@ -204,13 +202,32 @@ it("does not revive an unconsumed file intent after navigating away and back", ( ); act(() => owner.result.current.openDiffFile("left.ts")); - expect(owner.result.current.pendingGitDiffScrollPath).toBe("left.ts"); + expect(owner.result.current.pendingGitDiffTarget?.target).toEqual({ + kind: "file", + path: "left.ts", + }); owner.rerender({ environment: environmentB, threadId: "thread-b" }); - expect(owner.result.current.pendingGitDiffScrollPath).toBeNull(); + expect(owner.result.current.pendingGitDiffTarget).toBeNull(); owner.rerender({ environment: environmentA, threadId: "thread-a" }); - expect(owner.result.current.pendingGitDiffScrollPath).toBeNull(); + expect(owner.result.current.pendingGitDiffTarget).toBeNull(); +}); + +it("increments pane-local target identity when routing the same file twice", () => { + const owner = renderHook( + () => useMergeBaseOwner(makeEnvironment("env-left", "main"), "left"), + { wrapper: TestRoot }, + ); + + act(() => owner.result.current.openDiffFile("same.ts")); + const firstSequence = owner.result.current.pendingGitDiffTarget?.sequence; + expect(firstSequence).toBeTypeOf("number"); + + act(() => owner.result.current.openDiffFile("same.ts")); + expect(owner.result.current.pendingGitDiffTarget?.sequence).toBe( + (firstSequence ?? 0) + 1, + ); }); it("keeps delayed file and commit intents with the owner that requested them", async () => { @@ -224,20 +241,20 @@ it("keeps delayed file and commit intents with the owner that requested them", a ); act(() => left.result.current.openDiffFile("left.ts")); - expect(left.result.current.pendingGitDiffScrollPath).toBe("left.ts"); - expect(right.result.current.pendingGitDiffScrollPath).toBeNull(); - - act(() => right.result.current.clearPendingGitDiffIntent()); - expect(left.result.current.pendingGitDiffScrollPath).toBe("left.ts"); + expect(left.result.current.pendingGitDiffTarget?.target).toEqual({ + kind: "file", + path: "left.ts", + }); + expect(right.result.current.pendingGitDiffTarget).toBeNull(); - act(() => left.result.current.clearPendingGitDiffIntent()); - expect(left.result.current.pendingGitDiffScrollPath).toBeNull(); + act(() => left.result.current.pendingGitDiffTarget?.clear()); + expect(left.result.current.pendingGitDiffTarget).toBeNull(); act(() => left.result.current.openCommitDiff("sha-left")); await waitFor(() => { expect(left.result.current.gitDiffSelectValue).toBe("sha-left"); - expect(left.result.current.pendingGitDiffCommitSha).toBeNull(); + expect(left.result.current.pendingGitDiffTarget).toBeNull(); }); expect(right.result.current.gitDiffSelectValue).toBe("all"); - expect(right.result.current.pendingGitDiffCommitSha).toBeNull(); + expect(right.result.current.pendingGitDiffTarget).toBeNull(); }); diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts index 9ee1018a12..8459696a9a 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanel.ts @@ -1,4 +1,8 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import type { + ExperimentalChangesViewTarget, + ExperimentalChangesViewTargetState, +} from "@get-bb/plugin-sdk"; import { useEnvironmentMergeBaseBranches } from "../../../hooks/queries/environment-queries"; import type { SecondaryFixedPanelTab } from "@/lib/fixed-panel-tabs-state"; import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel"; @@ -22,19 +26,12 @@ interface SelectedMergeBaseBranchState { environmentId?: string; } -type PendingGitDiffIntent = - | { - environmentId?: string; - kind: "commit"; - sha: string; - threadId: string; - } - | { - environmentId?: string; - kind: "file"; - path: string; - threadId: string; - }; +type PendingGitDiffIntent = { + environmentId?: string; + sequence: number; + target: ExperimentalChangesViewTarget; + threadId: string; +}; export function useGitDiffPanel({ activeSecondaryTab, @@ -59,20 +56,13 @@ export function useGitDiffPanel({ ); const [pendingGitDiffIntent, setPendingGitDiffIntent] = useState(null); + const nextPendingGitDiffSequence = useRef(0); const currentPendingGitDiffIntent = pendingGitDiffIntent !== null && pendingGitDiffIntent.environmentId === environmentId && pendingGitDiffIntent.threadId === threadId ? pendingGitDiffIntent : null; - const pendingGitDiffCommitSha = - currentPendingGitDiffIntent?.kind === "commit" - ? currentPendingGitDiffIntent.sha - : null; - const pendingGitDiffScrollPath = - currentPendingGitDiffIntent?.kind === "file" - ? currentPendingGitDiffIntent.path - : null; const clearPendingGitDiffIntent = useCallback(() => { setPendingGitDiffIntent((current) => current !== null && @@ -82,6 +72,18 @@ export function useGitDiffPanel({ : current, ); }, [environmentId, threadId]); + const pendingGitDiffTarget = + useMemo( + () => + currentPendingGitDiffIntent === null + ? null + : { + sequence: currentPendingGitDiffIntent.sequence, + target: currentPendingGitDiffIntent.target, + clear: clearPendingGitDiffIntent, + }, + [clearPendingGitDiffIntent, currentPendingGitDiffIntent], + ); const [mergeBaseBranchSearchQuery, setMergeBaseBranchSearchQuery] = useState(""); const requestedMergeBaseBranch = @@ -136,35 +138,46 @@ export function useGitDiffPanel({ setThreadSecondaryPanel(null); }, [setThreadSecondaryPanel]); + const setPendingTarget = useCallback( + (target: ExperimentalChangesViewTarget) => { + nextPendingGitDiffSequence.current += 1; + setPendingGitDiffIntent({ + environmentId, + sequence: nextPendingGitDiffSequence.current, + target, + threadId, + }); + }, + [environmentId, threadId], + ); + const openDiffFile = useCallback( (path: string) => { clearActiveFileTabs(); - setPendingGitDiffIntent({ environmentId, kind: "file", path, threadId }); + setPendingTarget({ kind: "file", path }); openThreadDiffPanel(); }, - [clearActiveFileTabs, environmentId, openThreadDiffPanel, threadId], + [clearActiveFileTabs, openThreadDiffPanel, setPendingTarget], ); const openCommitDiff = useCallback( (sha: string) => { clearActiveFileTabs(); - setPendingGitDiffIntent({ environmentId, kind: "commit", sha, threadId }); + setPendingTarget({ kind: "commit", sha }); openThreadDiffPanel(); }, - [clearActiveFileTabs, environmentId, openThreadDiffPanel, threadId], + [clearActiveFileTabs, openThreadDiffPanel, setPendingTarget], ); return { closeThreadSecondaryPanel, - clearPendingGitDiffIntent, isLoadingMergeBaseBranchOptions, mergeBaseBranchOptions, mergeBaseRemoteBranchOptions, openCommitDiff, openDiffFile, openThreadDiffPanel, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, + pendingGitDiffTarget, requestedMergeBaseBranch, selectedMergeBaseBranch, selectedMergeBaseBranchRef, diff --git a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts index 5db0a0d3e5..bf65f01402 100644 --- a/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts +++ b/apps/app/src/components/secondary-panel/git-diff/useGitDiffPanelState.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useMemo, useState } from "react"; +import type { ExperimentalChangesViewTargetState } from "@get-bb/plugin-sdk"; import { useEnvironmentWorkStatus } from "../../../hooks/queries/environment-queries"; import type { GitDiffSelectionOption } from "../GitDiffToolbar"; import { @@ -9,13 +10,25 @@ import { type GitDiffSelectionValue, } from "./gitDiffPanelHelpers"; +/** + * The selection each environment's Changes tab last had. The tab unmounts + * whenever another right-panel view is active, so the choice lives here + * rather than in component state: leaving and coming back finds the same + * commit selected, and a different environment starts from all changes. + */ +const selectionByEnvironment = new Map(); + +function rememberedSelection(environmentId: string | undefined): GitDiffSelectionValue { + return environmentId === undefined + ? null + : (selectionByEnvironment.get(environmentId) ?? null); +} + interface UseGitDiffPanelStateParams { environmentId?: string; isDiffPanelActive: boolean; requestedMergeBaseBranch?: string; - onClearPendingGitDiffIntent?: () => void; - pendingGitDiffCommitSha?: string | null; - pendingGitDiffScrollPath?: string | null; + experimental_target: ExperimentalChangesViewTargetState | null; } /** @@ -24,22 +37,27 @@ interface UseGitDiffPanelStateParams { * a specific commit) — and the derived {@link buildGitDiffTarget} that the TOC + * patch fetches key on. The diff body ({@link GitDiffTabContent}) and the * per-file cards do all diff fetching, parsing, virtualization, and collapse - * state themselves; this hook holds none of that. It reacts to the info-tab / - * prompt-banner intents (`pendingGitDiffCommitSha` to scope to a commit, - * `pendingGitDiffScrollPath` to reset the diff to all-changes so the opened file - * is in the slice) and resets a stale selection when the workspace's commit list - * changes. + * state themselves; this hook holds none of that. It reacts to the pane's + * target state, scopes commit targets, resets file targets to all changes, and + * resets a stale selection when the workspace's commit list changes. */ export function useGitDiffPanelState({ environmentId, isDiffPanelActive, requestedMergeBaseBranch, - onClearPendingGitDiffIntent, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, + experimental_target, }: UseGitDiffPanelStateParams) { - const [selectedGitDiffSelection, setSelectedGitDiffSelection] = - useState(null); + const [selectedGitDiffSelection, setSelectedGitDiffSelectionState] = + useState(() => rememberedSelection(environmentId)); + const setSelectedGitDiffSelection = useCallback( + (value: GitDiffSelectionValue) => { + if (environmentId !== undefined) { + selectionByEnvironment.set(environmentId, value); + } + setSelectedGitDiffSelectionState(value); + }, + [environmentId], + ); const gitDiffTarget = useMemo( () => @@ -61,32 +79,36 @@ export function useGitDiffPanelState({ ? gitDiffWorkspaceStatus.workspace : undefined; - // --- Reset on environment change --- + // --- Follow the environment: its own remembered selection, or all changes --- useEffect(() => { - setSelectedGitDiffSelection(null); + setSelectedGitDiffSelectionState(rememberedSelection(environmentId)); }, [environmentId]); // --- Reset the diff to all-changes when an open-file intent arrives // (openDiffFile) so the opened file is in the slice. The scroll consumer - // (DiffFilesPanel) clears `pendingGitDiffScrollPath` once it scrolls the file - // into view. Clearing the intent also lets re-opening the same path re-fire + // (DiffFilesPanel) clears the target once it scrolls the file into view. + // Clearing the intent also lets re-opening the same path re-fire // this effect. --- useEffect(() => { - if (pendingGitDiffScrollPath) { + if (experimental_target?.target.kind === "file") { setSelectedGitDiffSelection(null); } - }, [pendingGitDiffScrollPath]); + }, [ + experimental_target?.sequence, + experimental_target?.target.kind, + setSelectedGitDiffSelection, + ]); // --- Apply the commit selection requested from the info tab (openCommitDiff) --- useEffect(() => { - if (pendingGitDiffCommitSha) { - setSelectedGitDiffSelection(pendingGitDiffCommitSha); - onClearPendingGitDiffIntent?.(); + if (experimental_target?.target.kind === "commit") { + setSelectedGitDiffSelection(experimental_target.target.sha); + experimental_target.clear(); } - }, [onClearPendingGitDiffIntent, pendingGitDiffCommitSha]); + }, [experimental_target, setSelectedGitDiffSelection]); const hasUncommittedChanges = (workspaceStatus?.workingTree.files.length ?? 0) > 0; @@ -104,6 +126,7 @@ export function useGitDiffPanelState({ }, [ hasUncommittedChanges, selectedGitDiffSelection, + setSelectedGitDiffSelection, workspaceStatus?.mergeBase?.commits, ]); @@ -119,11 +142,14 @@ export function useGitDiffPanelState({ [diffCommits, hasUncommittedChanges], ); - const onGitDiffSelectionChange = useCallback((value: string) => { - setSelectedGitDiffSelection( - value === ALL_GIT_DIFF_SELECTION ? null : value, - ); - }, []); + const onGitDiffSelectionChange = useCallback( + (value: string) => { + setSelectedGitDiffSelection( + value === ALL_GIT_DIFF_SELECTION ? null : value, + ); + }, + [setSelectedGitDiffSelection], + ); return { gitDiffTarget, diff --git a/apps/app/src/components/settings/CodeRendererSettings.test.tsx b/apps/app/src/components/settings/CodeRendererSettings.test.tsx index 0a708d840f..fcde1bda32 100644 --- a/apps/app/src/components/settings/CodeRendererSettings.test.tsx +++ b/apps/app/src/components/settings/CodeRendererSettings.test.tsx @@ -11,6 +11,7 @@ import { diffRendererProviderAtom, sourceCodeRendererProviderAtom, } from "@/components/code/codeRendererProvider"; +import { changesViewProviderAtom } from "@/components/secondary-panel/git-diff/changesViewProvider"; import { AUTOMATIC_REPLACEMENT_PROVIDER, BUILT_IN_REPLACEMENT_PROVIDER, @@ -43,6 +44,7 @@ describe("CodeRendererSettings", () => { expect(screen.queryByRole("button", { name: "Source code" })).toBeNull(); expect(screen.queryByRole("button", { name: "Diffs" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Changes" })).toBeNull(); }); it("pins BB's diff renderer without touching the source-code choice", async () => { @@ -78,6 +80,35 @@ describe("CodeRendererSettings", () => { ); }); + it("pins BB's Changes view independently from the global diff renderer", async () => { + setPluginSlotRegistrations("review", { + ...EMPTY_REGISTRATIONS, + experimentalChangesViews: [ + { id: "changes", title: "Review Changes", component: () => null }, + ], + diffRenderers: [ + { id: "diffs", title: "Review diffs", component: () => null }, + ], + }); + const store = createStore(); + render( + + + , + ); + + const changesTrigger = screen.getByRole("button", { name: "Changes" }); + fireEvent.pointerDown(changesTrigger, { button: 0 }); + fireEvent.click(await screen.findByRole("menuitem", { name: /built-in/u })); + + expect(store.get(changesViewProviderAtom)).toBe( + BUILT_IN_REPLACEMENT_PROVIDER, + ); + expect(store.get(diffRendererProviderAtom)).toBe( + AUTOMATIC_REPLACEMENT_PROVIDER, + ); + }); + it("offers each registered provider by name", () => { setPluginSlotRegistrations("inkwell", { ...EMPTY_REGISTRATIONS, @@ -121,9 +152,7 @@ describe("CodeRendererSettings", () => { true, ); expect( - items.some((text) => - text.includes("Side-by-side with word highlights."), - ), + items.some((text) => text.includes("Side-by-side with word highlights.")), ).toBe(true); }); }); diff --git a/apps/app/src/components/settings/CodeRendererSettings.tsx b/apps/app/src/components/settings/CodeRendererSettings.tsx index 58143be793..1f8ea1baf7 100644 --- a/apps/app/src/components/settings/CodeRendererSettings.tsx +++ b/apps/app/src/components/settings/CodeRendererSettings.tsx @@ -14,6 +14,7 @@ import { diffRendererProviderAtom, sourceCodeRendererProviderAtom, } from "@/components/code/codeRendererProvider"; +import { changesViewProviderAtom } from "@/components/secondary-panel/git-diff/changesViewProvider"; import { AUTOMATIC_REPLACEMENT_PROVIDER, BUILT_IN_REPLACEMENT_PROVIDER, @@ -121,9 +122,10 @@ function CodeRendererSetting({ ); } -/** Both code-renderer pins; each row hides itself when no plugin supplies one. */ +/** Replacement pins; each row hides itself when no plugin supplies one. */ export function CodeRendererSettings() { - const { sourceCodeRenderers, diffRenderers } = usePluginSlots(); + const { sourceCodeRenderers, diffRenderers, experimentalChangesViews } = + usePluginSlots(); return ( <> + ); } diff --git a/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx b/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx new file mode 100644 index 0000000000..aeba37f817 --- /dev/null +++ b/apps/app/src/components/settings/SidebarNavigationSetting.test.tsx @@ -0,0 +1,60 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { afterEach, describe, expect, it } from "vitest"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { sidebarNavigationProviderAtom } from "@/components/sidebar/sidebarNavigationProvider"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, +} from "@/lib/plugin-replacement-preference"; +import { SidebarNavigationSetting } from "./SidebarNavigationSetting"; + +afterEach(() => { + cleanup(); + window.localStorage.clear(); + resetPluginSlotStoreForTest(); +}); + +describe("SidebarNavigationSetting", () => { + it("defaults to automatic and lets the user pin BB's navigation", async () => { + setPluginSlotRegistrations("navbar", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + experimentalSidebarNavigations: [ + { + id: "grid", + title: "Navigation grid", + component: () => null, + }, + ], + fileOpeners: [], + messageDirectives: [], + }); + const store = createStore(); + render( + + + , + ); + + expect(store.get(sidebarNavigationProviderAtom)).toBe( + AUTOMATIC_REPLACEMENT_PROVIDER, + ); + const trigger = screen.getByRole("button", { + name: "Sidebar navigation", + }); + fireEvent.pointerDown(trigger, { button: 0 }); + fireEvent.click(await screen.findByRole("menuitem", { name: /built-in/u })); + expect(store.get(sidebarNavigationProviderAtom)).toBe( + BUILT_IN_REPLACEMENT_PROVIDER, + ); + }); +}); diff --git a/apps/app/src/components/settings/SidebarNavigationSetting.tsx b/apps/app/src/components/settings/SidebarNavigationSetting.tsx new file mode 100644 index 0000000000..b5c0fd334b --- /dev/null +++ b/apps/app/src/components/settings/SidebarNavigationSetting.tsx @@ -0,0 +1,96 @@ +import { useAtom } from "jotai"; +import { Icon } from "@bb/shared-ui/icon"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { COARSE_POINTER_ICON_SIZE_CLASS } from "@bb/shared-ui/coarse-pointer-sizing"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { SettingsWithControl } from "@/components/ui/settings-section"; +import { sidebarNavigationProviderAtom } from "@/components/sidebar/sidebarNavigationProvider"; +import { + AUTOMATIC_REPLACEMENT_PROVIDER, + BUILT_IN_REPLACEMENT_PROVIDER, + replacementProviderKey, +} from "@/lib/plugin-replacement-preference"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +const BUILT_IN_OPTION = { + key: BUILT_IN_REPLACEMENT_PROVIDER, + title: "bb (built-in)", + description: "Native New thread, Search, Extensions, and plugin panels.", +} as const; + +export function SidebarNavigationSetting() { + const { experimentalSidebarNavigations } = usePluginSlots(); + const [preference, setPreference] = useAtom(sidebarNavigationProviderAtom); + + const automaticProvider = experimentalSidebarNavigations[0]; + if (automaticProvider === undefined) return null; + const options = [ + { + key: AUTOMATIC_REPLACEMENT_PROVIDER, + title: "Automatic", + description: `Currently using ${automaticProvider.title} from ${automaticProvider.pluginId}.`, + }, + BUILT_IN_OPTION, + ...experimentalSidebarNavigations.map((slot) => ({ + key: replacementProviderKey(slot), + title: slot.title, + description: slot.description ?? `From the ${slot.pluginId} plugin.`, + })), + ]; + const selected = + options.find((option) => option.key === preference) ?? BUILT_IN_OPTION; + + return ( + + + + + + + {options.map((option) => ( + setPreference(option.key)} + className="flex items-start gap-2" + > + + {option.title} + + {option.description} + + + + + ))} + + + + ); +} diff --git a/apps/app/src/components/sidebar/AppSidebar.hidden-shortcuts.test.tsx b/apps/app/src/components/sidebar/AppSidebar.hidden-shortcuts.test.tsx new file mode 100644 index 0000000000..6c293b6afb --- /dev/null +++ b/apps/app/src/components/sidebar/AppSidebar.hidden-shortcuts.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { cleanup, render } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SidebarProvider } from "@/components/ui/sidebar"; +import { AppSidebar } from "./AppSidebar"; + +const mocks = vi.hoisted(() => ({ + handlers: new Map boolean>(), + openSearch: vi.fn(), +})); + +vi.mock("@/components/commands/AppCommandProvider", () => ({ + useAppCommandHandler: (id: string, handler: () => boolean) => + mocks.handlers.set(id, handler), + useAppCommandShortcut: () => null, + useAppCommandShortcuts: () => new Map(), + useIndexedAppCommandHandlers: () => {}, + useIsAppCommandModifierHeld: () => false, +})); +vi.mock("./useSidebarThreadSearch", () => ({ + useSidebarThreadSearch: () => ({ + activeDescendantId: undefined, + activeIndex: 0, + inputRef: { current: null }, + isActive: false, + onActivate: mocks.openSearch, + onActiveIndexChange: vi.fn(), + onClose: vi.fn(), + onExternalThreadOpen: vi.fn(), + onKeyDown: vi.fn(), + onNavigationItemsChange: vi.fn(), + onQueryChange: vi.fn(), + onSelectItem: vi.fn(), + query: "", + }), +})); +vi.mock("./SidebarNavigationRegion", () => ({ + SidebarNavigationRegion: () =>
Navigation
, +})); +vi.mock("./PluginThreadList", () => ({ + PluginThreadList: () =>
Threads
, +})); +vi.mock("./threadListProvider", () => ({ + useThreadListReplacement: () => ({ kind: "owner" }), +})); +vi.mock("@/components/plugin/PluginSidebarFooterActions", () => ({ + PluginSidebarFooterActions: () => null, +})); +vi.mock("./SidebarPluginAttentionGlyph", () => ({ + SidebarPluginAttentionGlyph: () => null, +})); +vi.mock("./SidebarUpdatesBadge", () => ({ SidebarUpdatesBadge: () => null })); +vi.mock("./SidebarHistoryNavigationControls", () => ({ + SidebarHistoryNavigationControls: () => null, +})); +vi.mock("@/hooks/useQuickCreateProject", () => ({ + useQuickCreateProjectController: () => ({ + isAvailable: false, + isCreating: false, + }), +})); +vi.mock("./usePaneContentSplitDrag", () => ({ + usePaneContentSplitDrag: () => ({ openInSplit: vi.fn() }), +})); +vi.mock("@bb/shared-ui/hooks/use-pointer-coarse", () => ({ + usePointerCoarse: () => false, +})); +vi.mock("@/hooks/useRouteState", () => ({ + useRouteState: () => ({ projectId: null, threadId: null }), +})); + +afterEach(() => { + cleanup(); + mocks.handlers.clear(); + mocks.openSearch.mockReset(); +}); + +describe("AppSidebar hidden hosted body shortcuts", () => { + it("does not let a retained hidden app body claim Search", () => { + const view = render( + + + + + , + ); + + expect(mocks.handlers.get("thread.search")?.()).toBe(false); + expect(mocks.openSearch).not.toHaveBeenCalled(); + + view.rerender( + + + + + , + ); + expect(mocks.handlers.get("thread.search")?.()).toBe(true); + expect(mocks.openSearch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/app/src/components/sidebar/AppSidebar.tsx b/apps/app/src/components/sidebar/AppSidebar.tsx index 48daad1f2d..283bf04a09 100644 --- a/apps/app/src/components/sidebar/AppSidebar.tsx +++ b/apps/app/src/components/sidebar/AppSidebar.tsx @@ -16,10 +16,10 @@ import { useCloseMobileSidebar, useSidebar, } from "@/components/ui/sidebar.js"; -import { ProjectList, ProjectListActionButtons } from "./ProjectList"; +import { ProjectList } from "./ProjectList"; import { PluginThreadList } from "./PluginThreadList"; import { useThreadListReplacement } from "./threadListProvider"; -import { PluginNavSidebarItems } from "@/components/plugin/PluginNavSidebarItems"; +import { SidebarNavigationRegion } from "./SidebarNavigationRegion"; import { PluginSidebarFooterActions } from "@/components/plugin/PluginSidebarFooterActions"; import { SidebarPluginAttentionGlyph } from "./SidebarPluginAttentionGlyph"; import { SidebarUpdatesBadge } from "./SidebarUpdatesBadge"; @@ -86,18 +86,18 @@ export function AppSidebar({ mobileHosted, }: AppSidebarProps) { const quickCreateProject = useQuickCreateProjectController(); - // The resolved replacement owns the sidebar's scrolling thread list. It never - // replaces the chrome around it: the New-thread button, search field, - // the plugin nav rows, and the footer stay host-rendered in every sidebar. + // Thread-list replacement stays independent from navigation replacement. + // AppSidebar retains both mount sites plus the search field and footer. const threadListReplacement = useThreadListReplacement(); const { threadId: activeThreadId } = useRouteState(); const navigate = useNavigate(); + const closeOnMobile = useCloseMobileSidebar(); const newThreadSplit = usePaneContentSplitDrag({ content: NEW_THREAD_PANE_CONTENT, enabled: true, label: "New thread", + onNavigate: closeOnMobile, }); - const closeOnMobile = useCloseMobileSidebar(); const { isCompactViewport, setOpen, setOpenMobile } = useSidebar(); const [desktopInfo] = useState(getBbDesktopInfo); const [threadShortcutKeysById, setThreadShortcutKeysById] = useState< @@ -270,14 +270,17 @@ export function AppSidebar({ isActive: threadSearch.isActive, onActiveIndexChange: threadSearch.onActiveIndexChange, onNavigationItemsChange: threadSearch.onNavigationItemsChange, + onNavigate: threadSearch.onExternalThreadOpen, onSelectItem: threadSearch.onSelectItem, query: threadSearch.query, + splitEnabled: true, }), [ threadSearch.activeIndex, threadSearch.isActive, threadSearch.onActiveIndexChange, threadSearch.onNavigationItemsChange, + threadSearch.onExternalThreadOpen, threadSearch.onSelectItem, threadSearch.query, ], @@ -329,29 +332,21 @@ export function AppSidebar({ />
) : null} -
- -
- & + ComponentProps; + +/** BB's complete native renderer for the replaceable navigation controls. */ +export function BuiltInSidebarNavigation({ + newThreadSplit, + onNavigate, + onNewChat, + splitEnabled, + threadSearch, + toolsRoutePath, +}: BuiltInSidebarNavigationProps) { + return ( +
+
+ +
+ +
+ ); +} diff --git a/apps/app/src/components/sidebar/PluginThreadList.tsx b/apps/app/src/components/sidebar/PluginThreadList.tsx index ea56c20e45..f7a5cbb690 100644 --- a/apps/app/src/components/sidebar/PluginThreadList.tsx +++ b/apps/app/src/components/sidebar/PluginThreadList.tsx @@ -14,7 +14,7 @@ interface PluginThreadListProps { replacement: ResolvedReplacement; /** BB's list bound to this sidebar instance. */ original: ReactNode; - /** The host search field's text; "" when closed or plugin-owned. */ + /** The host search field's text; "" when closed. */ searchQuery: string; onNavigate: () => void; } diff --git a/apps/app/src/components/sidebar/ProjectList.tsx b/apps/app/src/components/sidebar/ProjectList.tsx index 6049a3d64f..19da9b439d 100644 --- a/apps/app/src/components/sidebar/ProjectList.tsx +++ b/apps/app/src/components/sidebar/ProjectList.tsx @@ -796,6 +796,53 @@ function ProjectListNavigationLoadingRow({ ); } +export function ProjectListSearchInput({ + threadSearch, +}: { + threadSearch: SidebarThreadSearchInputController; +}) { + // The host always owns this combobox. A navigation replacement can activate + // search, but cannot mount, move, or replace the field itself. + return ( +
+ + + + threadSearch.onQueryChange(event.currentTarget.value) + } + /> + +
+ ); +} + export function ProjectListActionButtons({ splitEnabled = false, newThreadSplit, @@ -809,54 +856,11 @@ export function ProjectListActionButtons({ { kind: "new-thread" }, splitEnabled, ); - // One click on the X fully dismisses search — it clears the query and closes - // the input in a single step (onClose resets the query too). Previously this - // was a two-step clear-then-close, which felt like the X "needed two presses". - const handleSearchClose = useCallback(() => { - threadSearch?.onClose(); - }, [threadSearch]); return (
{threadSearch?.isActive ? ( -
- - - - threadSearch.onQueryChange(event.currentTarget.value) - } - /> - -
+ ) : (
+ ))} + + +
+ ); +} + +function LocationProbe() { + return {useLocation().pathname}; +} + +function RetainedOwner({ onMount }: { onMount: () => void }) { + useEffect(onMount, [onMount]); + return ( +
Retained thread list and footer
+ ); +} + +function Harness({ onOwnerMount }: { onOwnerMount: () => void }) { + const [searchActive, setSearchActive] = useState(false); + const [query, setQuery] = useState(""); + return ( + <> + setSearchActive(true), + onClose: () => { + setSearchActive(false); + setQuery(""); + }, + onQueryChange: setQuery, + query, + }} + /> + + + + ); +} + +function CompactHarness() { + const { closeMobileSidebar, openMobile, setOpenMobile } = useSidebar(); + useEffect(() => setOpenMobile(true), [setOpenMobile]); + return ( + <> + + + + + {openMobile ? "open" : "closed"} + + + ); +} + +function renderHarness(onOwnerMount = vi.fn()) { + const store = createStore(); + return render( + + + + + + + , + ); +} + +function registerFixture() { + setPluginSlotRegistrations( + "garden", + registrationSet({ + navPanels: [ + { + id: "docs", + title: "Docs", + icon: "BookOpen", + path: "docs", + component: () => null, + }, + ], + experimentalSidebarNavigations: [ + { + id: "navbar", + title: "Garden Navbar", + component: Replacement, + }, + ], + }), + ); +} + +afterEach(() => { + cleanup(); + resetAllCrashedPluginSlotsForTest(); + resetPluginSlotStoreForTest(); + window.localStorage.clear(); + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +describe("SidebarNavigationRegion", () => { + it("activates host Search and keeps the host query field outside plugin ownership", () => { + registerFixture(); + renderHarness(); + + fireEvent.click(screen.getByRole("button", { name: "Search threads" })); + const input = screen.getByRole("combobox", { name: "Search threads" }); + expect(input.closest("[data-bb-plugin]")).toBeNull(); + fireEvent.change(input, { target: { value: "release" } }); + expect((input as HTMLInputElement).value).toBe("release"); + expect(screen.queryByTestId("replacement-navigation")).toBeNull(); + }); + + it("navigates to a current plugin destination through the host", () => { + registerFixture(); + renderHarness(); + + fireEvent.click(screen.getByRole("button", { name: "Docs" })); + expect(screen.getByTestId("pathname").textContent).toBe( + "/plugins/garden/docs", + ); + }); + + it("closes the compact drawer after plugin-destination navigation", () => { + vi.useFakeTimers(); + registerFixture(); + render( + + + + + + + + + , + ); + expect(screen.getByTestId("drawer-state").textContent).toBe("open"); + + fireEvent.click(screen.getByRole("button", { name: "Docs" })); + vi.advanceTimersByTime(220); + expect(screen.getByTestId("drawer-state").textContent).toBe("closed"); + }); + + it("delegates and crash-falls back without remounting retained owners", () => { + vi.spyOn(console, "warn").mockImplementation(() => {}); + vi.spyOn(console, "error").mockImplementation(() => {}); + registerFixture(); + const ownerMount = vi.fn(); + renderHarness(ownerMount); + expect(ownerMount).toHaveBeenCalledOnce(); + + fireEvent.click(screen.getByRole("button", { name: "Delegate to BB" })); + expect(screen.getByTestId("built-in-sidebar-navigation")).toBeDefined(); + expect(ownerMount).toHaveBeenCalledOnce(); + + cleanup(); + resetAllCrashedPluginSlotsForTest(); + renderHarness(ownerMount); + fireEvent.click(screen.getByRole("button", { name: "Crash replacement" })); + expect(screen.getByTestId("built-in-sidebar-navigation")).toBeDefined(); + expect(ownerMount).toHaveBeenCalledTimes(2); + }); +}); diff --git a/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx b/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx new file mode 100644 index 0000000000..97ca21378f --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarNavigationRegion.tsx @@ -0,0 +1,264 @@ +import { + useCallback, + useLayoutEffect, + useMemo, + useRef, + type PointerEvent as ReactPointerEvent, +} from "react"; +import type { + ExperimentalSidebarNavigationAction, + ExperimentalSidebarNavigationActivationOptions, + ExperimentalSidebarNavigationItem, +} from "@get-bb/plugin-sdk"; +import { useLocation, useNavigate } from "react-router-dom"; +import { toast } from "sonner"; +import { useAppCommandShortcut } from "@/components/commands/AppCommandProvider"; +import { PluginReplacementSlot } from "@/components/plugin/PluginReplacementSlot"; +import { useSidebar } from "@/components/ui/sidebar"; +import { usePluginSlots } from "@/lib/plugin-slots"; +import { getPluginPanelRoutePath } from "@/lib/route-paths"; +import { + BuiltInSidebarNavigation, + type BuiltInSidebarNavigationProps, +} from "./BuiltInSidebarNavigation"; +import { ProjectListSearchInput } from "./ProjectList"; +import { + activateSidebarNavigationItem, + createSidebarNavigationItems, + resolveActiveSidebarNavigationItemId, +} from "./sidebarNavigationItems"; +import { useSidebarNavigationReplacement } from "./sidebarNavigationProvider"; +import { usePaneContentSplitActions } from "./usePaneContentSplitDrag"; + +const SIDEBAR_NAVIGATION_SLOT_KIND = "sidebarNavigation"; +const NEW_THREAD_CONTENT = { kind: "new-thread" } as const; + +function contentForAction( + action: ExperimentalSidebarNavigationAction, + navPanels: ReturnType["navPanels"], +) { + if (action.kind === "new-thread") return NEW_THREAD_CONTENT; + if (action.kind !== "open-plugin-panel") return null; + const panel = navPanels.find( + (candidate) => + candidate.pluginId === action.pluginId && candidate.id === action.panelId, + ); + return panel + ? ({ + kind: "plugin-panel", + pluginId: panel.pluginId, + panelPath: panel.path, + subPath: "", + } as const) + : null; +} + +/** + * Owns the complete bounded navigation replacement. Search swaps in BB's + * combobox above the retained thread-list mount; drawer, list, footer, resize, + * and shortcut ownership remain in AppSidebar. + */ +export function SidebarNavigationRegion(props: BuiltInSidebarNavigationProps) { + const { navPanels } = usePluginSlots(); + const replacement = useSidebarNavigationReplacement(); + const { isCompactViewport } = useSidebar(); + const location = useLocation(); + const navigate = useNavigate(); + const splitActions = usePaneContentSplitActions(); + const newThreadShortcut = useAppCommandShortcut("thread.new"); + const threadSearchShortcut = useAppCommandShortcut("thread.search"); + + const splitPropsFor = useCallback( + ( + action: ExperimentalSidebarNavigationAction, + label: string, + ): ExperimentalSidebarNavigationItem["experimental_splitProps"] => { + const content = contentForAction(action, navPanels); + if (content === null || splitActions.isCompact) return {}; + return { + onPointerDown: (event: ReactPointerEvent) => + splitActions.beginDrag(event, { + content, + enabled: props.splitEnabled ?? false, + label, + onNavigate: props.onNavigate, + }), + }; + }, + [navPanels, props.onNavigate, props.splitEnabled, splitActions], + ); + const items = useMemo( + () => + createSidebarNavigationItems({ + navPanels, + newThreadDisabled: props.onNewChat === undefined, + newThreadShortcut: newThreadShortcut + ? { + label: newThreadShortcut.label, + ariaKeyShortcuts: newThreadShortcut.ariaKeyshortcuts, + } + : null, + searchThreadsDisabled: props.threadSearch === undefined, + searchThreadsShortcut: threadSearchShortcut + ? { + label: threadSearchShortcut.label, + ariaKeyShortcuts: threadSearchShortcut.ariaKeyshortcuts, + } + : null, + showExtensions: props.toolsRoutePath !== undefined, + splitPropsFor, + }), + [ + navPanels, + newThreadShortcut, + props.onNewChat, + props.threadSearch, + props.toolsRoutePath, + splitPropsFor, + threadSearchShortcut, + ], + ); + const activeItemId = resolveActiveSidebarNavigationItemId({ + items, + pathname: location.pathname, + navPanels, + }); + const replacementIdentity = + replacement.kind === "plugin" + ? `${replacement.registration.pluginId}/${replacement.registration.id}/${replacement.registration.generation}` + : "owner"; + const activationRef = useRef({ + replacementIdentity, + items, + navPanels, + props, + navigate, + splitActions, + }); + useLayoutEffect(() => { + activationRef.current = { + replacementIdentity, + items, + navPanels, + props, + navigate, + splitActions, + }; + }, [items, navPanels, navigate, props, replacementIdentity, splitActions]); + + const handleActivate = useCallback( + ( + identity: string, + itemId: string, + options: ExperimentalSidebarNavigationActivationOptions, + ) => { + const current = activationRef.current; + if (identity !== current.replacementIdentity) return; + activateSidebarNavigationItem( + current.items, + itemId, + options.openInSplit, + { + newThread: (openInSplit) => { + if (!openInSplit) { + current.props.onNewChat?.(); + return; + } + current.splitActions.openInSplit({ + content: NEW_THREAD_CONTENT, + enabled: current.props.splitEnabled ?? false, + label: "New thread", + onNavigate: current.props.onNavigate, + }); + }, + searchThreads: () => current.props.threadSearch?.onActivate(), + openExtensions: () => { + if (current.props.toolsRoutePath === undefined) return; + current.props.onNavigate?.(); + void current.navigate(current.props.toolsRoutePath); + }, + openPluginPanel: (action, openInSplit) => { + const panel = current.navPanels.find( + (candidate) => + candidate.pluginId === action.pluginId && + candidate.id === action.panelId, + ); + if (!panel) return; + if (openInSplit) { + current.splitActions.openInSplit({ + content: { + kind: "plugin-panel", + pluginId: panel.pluginId, + panelPath: panel.path, + subPath: "", + }, + enabled: current.props.splitEnabled ?? false, + label: panel.title, + onNavigate: current.props.onNavigate, + }); + return; + } + current.props.onNavigate?.(); + void current.navigate( + getPluginPanelRoutePath({ + pluginId: panel.pluginId, + path: panel.path, + }), + ); + }, + }, + ); + }, + [], + ); + + // Activating Search replaces only the bounded nav controls with this + // owner-rendered field. The thread list below remains mounted and receives + // the live query through its existing host contract. + if (props.threadSearch?.isActive) { + return ( +
+ +
+ ); + } + + const original = ; + const title = + replacement.kind === "plugin" ? replacement.registration.title : "Plugin"; + return ( + + ); +} diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.split.test.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.split.test.tsx new file mode 100644 index 0000000000..0e37fb7fa1 --- /dev/null +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.split.test.tsx @@ -0,0 +1,141 @@ +// @vitest-environment jsdom + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { createStore, Provider } from "jotai"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ThreadListEntry } from "@bb/domain"; +import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { useThreadSearch } from "@/hooks/queries/thread-queries"; +import { splitLayoutAtom } from "@/lib/split-layout/atoms"; +import { countPanes, type SplitLayout } from "@/lib/split-layout"; +import { beginSplitDrag } from "@/lib/split-drag"; +import { SidebarThreadSearchPanel } from "./SidebarThreadSearchPanel"; + +const mocks = vi.hoisted(() => ({ beginSplitDrag: vi.fn() })); + +vi.mock("@/hooks/queries/thread-queries", () => ({ + hasThreadSearchableQuery: () => false, + useThreadSearch: vi.fn(), +})); +vi.mock("@/lib/split-drag", async (importOriginal) => ({ + ...(await importOriginal()), + beginSplitDrag: mocks.beginSplitDrag, +})); + +const THREAD: ThreadListEntry = { + activity: { + activeWorkflowCount: 0, + activeBackgroundAgentCount: 0, + activeBackgroundCommandCount: 0, + activePlanModeCount: 0, + activeGoalCount: 0, + }, + archivedAt: null, + createdAt: 1, + deletedAt: null, + environmentBranchName: null, + environmentHostId: null, + environmentId: null, + environmentName: null, + environmentWorkspaceDisplayKind: "other", + hasPendingInteraction: false, + id: "thr_search", + lastReadAt: null, + latestAttentionAt: 1, + originKind: null, + originPluginId: null, + visibility: "visible", + parentThreadId: null, + pinSortKey: null, + pinnedAt: null, + projectId: "proj_search", + providerId: "codex", + runtime: { displayStatus: "idle", hostReconnectGraceExpiresAt: null }, + sectionId: null, + sourceThreadId: null, + status: "idle", + title: "Search result", + titleFallback: null, + updatedAt: 1, +}; + +function onePaneLayout(): SplitLayout { + return { + focusedPaneId: "pane-current", + root: { + type: "pane", + paneId: "pane-current", + content: { + kind: "thread", + projectId: "proj_current", + threadId: "thr_current", + }, + }, + }; +} + +afterEach(() => { + cleanup(); + mocks.beginSplitDrag.mockReset(); + vi.clearAllMocks(); +}); + +describe("SidebarThreadSearchPanel split navigation", () => { + it("preserves normal, modifier-click, and drag-to-split behavior", () => { + vi.mocked(useThreadSearch).mockReturnValue({ + data: undefined, + debouncedQuery: "", + isDebouncing: false, + isError: false, + isFetching: false, + isLoading: false, + hasSearchableQuery: false, + }); + const store = createStore(); + store.set(splitLayoutAtom, onePaneLayout()); + const onNavigate = vi.fn(); + const onSelect = vi.fn(); + render( + + + +
+ + + + , + ); + const row = screen.getByRole("option"); + + fireEvent.click(row); + expect(onSelect).toHaveBeenCalledOnce(); + + fireEvent.click(row, { ctrlKey: true }); + expect(onSelect).toHaveBeenCalledOnce(); + expect(onNavigate).toHaveBeenCalledOnce(); + expect(countPanes(store.get(splitLayoutAtom)!.root)).toBe(2); + + store.set(splitLayoutAtom, onePaneLayout()); + onNavigate.mockClear(); + fireEvent.pointerDown(row, { button: 0, clientX: 10, clientY: 10 }); + expect(mocks.beginSplitDrag).toHaveBeenCalledOnce(); + const drag = mocks.beginSplitDrag.mock.calls[0]?.[0] as Parameters< + typeof beginSplitDrag + >[0]; + drag.onDrop({ paneId: "pane-current", zone: "right" }); + expect(onNavigate).toHaveBeenCalledOnce(); + expect(countPanes(store.get(splitLayoutAtom)!.root)).toBe(2); + }); +}); diff --git a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx index c4f2a36c9c..dace4b42d5 100644 --- a/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx +++ b/apps/app/src/components/sidebar/SidebarThreadSearchPanel.tsx @@ -17,6 +17,7 @@ import { type SidebarThreadSearchNavigationItem, } from "./sidebarThreadSearch"; import { ThreadSearchResultRow } from "./ThreadSearchResultRow"; +import { usePaneContentSplitDrag } from "./usePaneContentSplitDrag"; interface SidebarThreadSearchPanelProps { activeIndex: number; @@ -26,10 +27,12 @@ interface SidebarThreadSearchPanelProps { onNavigationItemsChange: ( items: readonly SidebarThreadSearchNavigationItem[], ) => void; + onNavigate?: () => void; onSelect: (item: SidebarThreadSearchNavigationItem) => void; projectNamesById: ReadonlyMap; query: string; recentThreads: readonly ThreadListEntry[]; + splitEnabled?: boolean; showSectionLabels?: boolean; } @@ -55,6 +58,7 @@ interface ThreadSearchMessageProps { const RECENT_THREAD_LIMIT = 20; const EMPTY_MATCHES: readonly ThreadSearchMatch[] = []; const EMPTY_SECTION_NAMES_BY_ID = new Map(); +const NOOP = () => {}; // The message (non-title) match drives the deep-link target. Mirrors the row's // snippet selection so clicking a result lands on the message shown in the row. function getMessageMatchSeq( @@ -104,23 +108,76 @@ function ThreadSearchMessage({ ); } +function SplitThreadSearchResultRow({ + activeIndex, + index, + item, + matches, + onActiveIndexChange, + onNavigate, + onSelect, + projectName, + sectionLabel, + thread, +}: { + activeIndex: number; + index: number; + item: SidebarThreadSearchNavigationItem; + matches: readonly ThreadSearchMatch[]; + onActiveIndexChange: (index: number) => void; + onNavigate: () => void; + onSelect: (item: SidebarThreadSearchNavigationItem) => void; + projectName: string | undefined; + sectionLabel: string | null; + thread: ThreadListEntry; +}) { + const split = usePaneContentSplitDrag({ + content: { + kind: "thread", + projectId: item.projectId, + threadId: item.threadId, + }, + enabled: true, + label: thread.title ?? "Untitled thread", + onNavigate, + }); + return ( + onActiveIndexChange(index)} + onOpenInSplit={split.openInSplit} + onPointerDown={split.onPointerDown} + onSelect={() => onSelect(item)} + /> + ); +} + function renderSectionRows({ activeIndex, sectionNamesById, onActiveIndexChange, + onNavigate, onSelect, projectNamesById, section, showSectionLabels, + splitEnabled, startIndex, }: { activeIndex: number; sectionNamesById: ReadonlyMap; onActiveIndexChange: (index: number) => void; + onNavigate: () => void; onSelect: (item: SidebarThreadSearchNavigationItem) => void; projectNamesById: ReadonlyMap; section: ThreadSearchSection; showSectionLabels: boolean; + splitEnabled: boolean; startIndex: number; }) { if (section.rows.length === 0) { @@ -151,22 +208,35 @@ function renderSectionRows({ {section.rows.map((row, rowIndex) => { const index = startIndex + rowIndex; const item = toNavigationItem(row); - return ( - onActiveIndexChange(index), + onSelect: () => onSelect(item), + }; + return splitEnabled ? ( + onActiveIndexChange(index)} - onSelect={() => onSelect(item)} + onActiveIndexChange={onActiveIndexChange} + onNavigate={onNavigate} + onSelect={onSelect} /> + ) : ( + ); })}
@@ -180,10 +250,12 @@ export function SidebarThreadSearchPanel({ isRecentsLoading, onActiveIndexChange, onNavigationItemsChange, + onNavigate = NOOP, onSelect, projectNamesById, query, recentThreads, + splitEnabled = false, showSectionLabels = false, }: SidebarThreadSearchPanelProps) { const trimmedQuery = query.trim(); @@ -326,10 +398,12 @@ export function SidebarThreadSearchPanel({ activeIndex, sectionNamesById, onActiveIndexChange, + onNavigate, onSelect, projectNamesById, section, showSectionLabels, + splitEnabled, startIndex, }); startIndex += section.rows.length; diff --git a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx index 9e43a0a5d5..4f8054b35c 100644 --- a/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx +++ b/apps/app/src/components/sidebar/ThreadSearchResultRow.tsx @@ -4,6 +4,7 @@ import { useEffect, useRef, type MouseEventHandler, + type PointerEventHandler, type ReactNode, } from "react"; import type { ThreadListEntry } from "@bb/domain"; @@ -38,6 +39,8 @@ interface ThreadSearchResultRowProps { isActive: boolean; matches: readonly ThreadSearchMatch[]; onActive: () => void; + onOpenInSplit?: () => void; + onPointerDown?: PointerEventHandler; onSelect: () => void; projectName: string | undefined; /** @@ -123,6 +126,8 @@ function ThreadSearchResultRowComponent({ isActive, matches, onActive, + onOpenInSplit, + onPointerDown, onSelect, projectName, sectionLabel, @@ -200,7 +205,14 @@ function ThreadSearchResultRowComponent({ )} onMouseEnter={handleMouseEnter} onFocus={onActive} - onClick={onSelect} + onPointerDown={onPointerDown} + onClick={(event) => { + if ((event.metaKey || event.ctrlKey) && onOpenInSplit) { + onOpenInSplit(); + return; + } + onSelect(); + }} > diff --git a/apps/app/src/components/sidebar/sidebarNavigationItems.ts b/apps/app/src/components/sidebar/sidebarNavigationItems.ts new file mode 100644 index 0000000000..464d76a3b9 --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarNavigationItems.ts @@ -0,0 +1,163 @@ +import type { + ExperimentalSidebarNavigationAction, + ExperimentalSidebarNavigationItem, + ExperimentalSidebarNavigationShortcut, +} from "@get-bb/plugin-sdk"; +import type { PluginNavPanelSlot } from "@/lib/plugin-slots"; +import { getPluginPanelRoutePath, isToolsRoutePath } from "@/lib/route-paths"; + +export const NEW_THREAD_NAVIGATION_ITEM_ID = "new-thread"; +export const SEARCH_THREADS_NAVIGATION_ITEM_ID = "search-threads"; +export const EXTENSIONS_NAVIGATION_ITEM_ID = "extensions"; + +export function getPluginPanelNavigationItemId( + panel: Pick, +): string { + return `plugin-panel:${encodeURIComponent(panel.pluginId)}/${encodeURIComponent(panel.id)}`; +} + +type SplitProps = ExperimentalSidebarNavigationItem["experimental_splitProps"]; + +interface CreateSidebarNavigationItemsOptions { + navPanels: readonly PluginNavPanelSlot[]; + newThreadDisabled: boolean; + newThreadShortcut: ExperimentalSidebarNavigationShortcut | null; + searchThreadsDisabled: boolean; + searchThreadsShortcut: ExperimentalSidebarNavigationShortcut | null; + showExtensions: boolean; + splitPropsFor( + action: ExperimentalSidebarNavigationAction, + label: string, + ): SplitProps; +} + +export function createSidebarNavigationItems({ + navPanels, + newThreadDisabled, + newThreadShortcut, + searchThreadsDisabled, + searchThreadsShortcut, + showExtensions, + splitPropsFor, +}: CreateSidebarNavigationItemsOptions): readonly ExperimentalSidebarNavigationItem[] { + const newThreadAction = { kind: "new-thread" } as const; + const searchAction = { kind: "search-threads" } as const; + const extensionsAction = { kind: "open-extensions" } as const; + return [ + { + id: NEW_THREAD_NAVIGATION_ITEM_ID, + label: "New thread", + icon: { kind: "host", name: "new-thread" }, + action: newThreadAction, + isDisabled: newThreadDisabled, + shortcut: newThreadShortcut, + experimental_splitProps: splitPropsFor(newThreadAction, "New thread"), + }, + { + id: SEARCH_THREADS_NAVIGATION_ITEM_ID, + label: "Search threads", + icon: { kind: "host", name: "search" }, + action: searchAction, + isDisabled: searchThreadsDisabled, + shortcut: searchThreadsShortcut, + experimental_splitProps: {}, + }, + ...(showExtensions + ? [ + { + id: EXTENSIONS_NAVIGATION_ITEM_ID, + label: "Extensions", + icon: { kind: "host", name: "extensions" }, + action: extensionsAction, + isDisabled: false, + shortcut: null, + experimental_splitProps: {}, + } satisfies ExperimentalSidebarNavigationItem, + ] + : []), + ...navPanels.map((panel): ExperimentalSidebarNavigationItem => { + const action = { + kind: "open-plugin-panel", + pluginId: panel.pluginId, + panelId: panel.id, + } as const; + return { + id: getPluginPanelNavigationItemId(panel), + label: panel.title, + icon: { + kind: "plugin", + pluginId: panel.pluginId, + icon: panel.icon, + }, + action, + isDisabled: false, + shortcut: null, + experimental_splitProps: splitPropsFor(action, panel.title), + }; + }), + ]; +} + +export function resolveActiveSidebarNavigationItemId({ + items, + pathname, + navPanels, +}: { + items: readonly ExperimentalSidebarNavigationItem[]; + pathname: string; + navPanels: readonly PluginNavPanelSlot[]; +}): string | null { + if (pathname === "/") return NEW_THREAD_NAVIGATION_ITEM_ID; + if (isToolsRoutePath(pathname)) { + return items.some((item) => item.id === EXTENSIONS_NAVIGATION_ITEM_ID) + ? EXTENSIONS_NAVIGATION_ITEM_ID + : null; + } + for (const panel of navPanels) { + const path = getPluginPanelRoutePath({ + pluginId: panel.pluginId, + path: panel.path, + }); + if (pathname === path || pathname.startsWith(`${path}/`)) { + return getPluginPanelNavigationItemId(panel); + } + } + return null; +} + +export interface SidebarNavigationActivationHandlers { + newThread(openInSplit: boolean): void; + searchThreads(): void; + openExtensions(): void; + openPluginPanel( + action: Extract< + ExperimentalSidebarNavigationAction, + { kind: "open-plugin-panel" } + >, + openInSplit: boolean, + ): void; +} + +export function activateSidebarNavigationItem( + items: readonly ExperimentalSidebarNavigationItem[], + itemId: string, + openInSplit: boolean, + handlers: SidebarNavigationActivationHandlers, +): void { + const item = items.find((candidate) => candidate.id === itemId); + if (!item || item.isDisabled) return; + + switch (item.action.kind) { + case "new-thread": + handlers.newThread(openInSplit); + return; + case "search-threads": + handlers.searchThreads(); + return; + case "open-extensions": + handlers.openExtensions(); + return; + case "open-plugin-panel": + handlers.openPluginPanel(item.action, openInSplit); + } +} diff --git a/apps/app/src/components/sidebar/sidebarNavigationProvider.ts b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts new file mode 100644 index 0000000000..faab9ce1e9 --- /dev/null +++ b/apps/app/src/components/sidebar/sidebarNavigationProvider.ts @@ -0,0 +1,25 @@ +import { useAtomValue } from "jotai"; +import { + createReplacementPreferenceAtom, + resolvePreferredReplacement, +} from "@/lib/plugin-replacement-preference"; +import type { ResolvedReplacement } from "@/lib/plugin-slot-resolvers"; +import { + usePluginSlots, + type ExperimentalSidebarNavigationSlot, +} from "@/lib/plugin-slots"; + +const SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY = "bb.sidebar.navigationProvider"; + +export const sidebarNavigationProviderAtom = createReplacementPreferenceAtom( + SIDEBAR_NAVIGATION_PROVIDER_STORAGE_KEY, +); + +export function useSidebarNavigationReplacement(): ResolvedReplacement { + const { experimentalSidebarNavigations } = usePluginSlots(); + const preference = useAtomValue(sidebarNavigationProviderAtom); + return resolvePreferredReplacement( + experimentalSidebarNavigations, + preference, + ); +} diff --git a/apps/app/src/components/sidebar/sidebarThreadSearch.ts b/apps/app/src/components/sidebar/sidebarThreadSearch.ts index 70274e52d9..d2705032ed 100644 --- a/apps/app/src/components/sidebar/sidebarThreadSearch.ts +++ b/apps/app/src/components/sidebar/sidebarThreadSearch.ts @@ -34,8 +34,12 @@ export interface SidebarThreadSearchPanelController { onNavigationItemsChange: ( items: readonly SidebarThreadSearchNavigationItem[], ) => void; + /** Ends host search after a split click or drag navigates directly. */ + onNavigate: () => void; onSelectItem: (item: SidebarThreadSearchNavigationItem) => void; query: string; + /** Enables host split bindings for search results in the app sidebar. */ + splitEnabled?: boolean; } /** diff --git a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts index 8ab975247d..42a793561f 100644 --- a/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts +++ b/apps/app/src/components/sidebar/usePaneContentSplitDrag.ts @@ -1,4 +1,8 @@ -import { useCallback, type PointerEvent as ReactPointerEvent } from "react"; +import { + useCallback, + useMemo, + type PointerEvent as ReactPointerEvent, +} from "react"; import { useStore } from "jotai"; import { useNavigate } from "react-router-dom"; import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; @@ -39,41 +43,51 @@ function routeForContent(content: PaneContent): string { }); } -/** Prototype drag/cmd-click source for non-thread pages. */ -export function usePaneContentSplitDrag({ - content, - enabled, - label, -}: { +interface PaneContentSplitOptions { content: PaneContent; enabled: boolean; label: string; -}) { + onNavigate?: () => void; +} + +/** + * One controller for dynamic sidebar destinations. The navigation replacement + * uses it to bind the same split policy to a changing nav-panel item list + * without calling one hook per plugin registration. + */ +export function usePaneContentSplitActions() { const store = useStore(); const navigate = useNavigate(); const isCompact = useIsCompactViewport(); - const openInSplit = useCallback(() => { - const route = routeForContent(content); - const layout = store.get(splitLayoutAtom); - if (!enabled || isCompact || layout === null) { - navigate(route); - return; - } - const existing = findPaneByContent(layout.root, content); - const next = - existing !== null - ? setFocus(layout, existing.paneId) - : countPanes(layout.root) >= MAX_PANES - ? replacePaneContent(layout, layout.focusedPaneId, content) - : splitPane(layout, layout.focusedPaneId, "right", content); - if (next !== layout) store.set(splitLayoutAtom, next); - navigate(route, existing !== null ? { replace: true } : undefined); - }, [content, enabled, isCompact, navigate, store]); + const openInSplit = useCallback( + ({ content, enabled, onNavigate }: PaneContentSplitOptions) => { + const route = routeForContent(content); + const layout = store.get(splitLayoutAtom); + onNavigate?.(); + if (!enabled || isCompact || layout === null) { + navigate(route); + return; + } + const existing = findPaneByContent(layout.root, content); + const next = + existing !== null + ? setFocus(layout, existing.paneId) + : countPanes(layout.root) >= MAX_PANES + ? replacePaneContent(layout, layout.focusedPaneId, content) + : splitPane(layout, layout.focusedPaneId, "right", content); + if (next !== layout) store.set(splitLayoutAtom, next); + navigate(route, existing !== null ? { replace: true } : undefined); + }, + [isCompact, navigate, store], + ); - const onPointerDown = useCallback( - (event: ReactPointerEvent) => { - if (!enabled || event.button !== 0) return; + const beginDrag = useCallback( + ( + event: ReactPointerEvent, + { content, enabled, label, onNavigate }: PaneContentSplitOptions, + ) => { + if (!enabled || isCompact || event.button !== 0) return; const rowEl = event.currentTarget; const sidebarEl = rowEl.closest(SIDEBAR_SELECTOR); const sidebarRightEdge = (sidebarEl ?? rowEl).getBoundingClientRect() @@ -115,6 +129,7 @@ export function usePaneContentSplitDrag({ ? replacePaneContent(layout, target.paneId, content) : splitPane(layout, target.paneId, target.zone, content); if (next !== layout) store.set(splitLayoutAtom, next); + onNavigate?.(); navigate( routeForContent(content), existing !== null ? { replace: true } : undefined, @@ -122,11 +137,31 @@ export function usePaneContentSplitDrag({ }, }); }, - [content, enabled, label, navigate, store], + [isCompact, navigate, store], + ); + + return useMemo( + () => ({ beginDrag, isCompact, openInSplit }), + [beginDrag, isCompact, openInSplit], + ); +} + +/** Split-drag and modifier-click bindings for one stable sidebar destination. */ +export function usePaneContentSplitDrag(options: PaneContentSplitOptions) { + const actions = usePaneContentSplitActions(); + const openInSplit = useCallback( + () => actions.openInSplit(options), + [actions, options], + ); + const onPointerDown = useCallback( + (event: ReactPointerEvent) => + actions.beginDrag(event, options), + [actions, options], ); return { - onPointerDown: enabled && !isCompact ? onPointerDown : undefined, + onPointerDown: + options.enabled && !actions.isCompact ? onPointerDown : undefined, openInSplit, }; } diff --git a/apps/app/src/components/tools/PluginCapabilities.tsx b/apps/app/src/components/tools/PluginCapabilities.tsx index 888c85746b..41bdcf5a62 100644 --- a/apps/app/src/components/tools/PluginCapabilities.tsx +++ b/apps/app/src/components/tools/PluginCapabilities.tsx @@ -216,6 +216,13 @@ function pluginAppSurfaceItems( (section) => `${getRootComposeRoutePath()}#${getPluginHomepageSectionAnchor(pluginId, section.id)}`, ), + ...namedSlotItems( + pluginId, + slots.experimentalSidebarNavigations, + "sidebar-navigation", + "Replaces sidebar navigation controls; configured in Appearance.", + () => getSettingsRoutePath("appearance"), + ), ...namedSlotItems( pluginId, slots.threadLists, @@ -235,6 +242,13 @@ function pluginAppSurfaceItems( "diff-renderer", "Replaces how diffs are displayed everywhere in the app.", ), + ...namedSlotItems( + pluginId, + slots.experimentalChangesViews, + "changes-view", + "Replaces the complete Changes toolbar and file list; configured in Appearance.", + () => getSettingsRoutePath("appearance"), + ), ...namedSlotItems( pluginId, slots.threadPanelActions, diff --git a/apps/app/src/lib/plugin-sdk-app-impl.tsx b/apps/app/src/lib/plugin-sdk-app-impl.tsx index 83d16e5bd7..eec629d0d3 100644 --- a/apps/app/src/lib/plugin-sdk-app-impl.tsx +++ b/apps/app/src/lib/plugin-sdk-app-impl.tsx @@ -3,6 +3,7 @@ import type { MarkdownProps, PluginSdkApp } from "@get-bb/plugin-sdk"; import { PluginDiff } from "@/components/plugin/PluginDiff"; import { PluginNewThreadComposer } from "@/components/plugin/PluginNewThreadComposer"; import { PluginProviderModelPicker } from "@/components/plugin/PluginProviderModelPicker"; +import { PluginResponsiveDrawer } from "@/components/plugin/PluginResponsiveDrawer"; import { PluginPermissionModePicker } from "@/components/plugin/PluginPermissionModePicker"; import { PluginSourceCode } from "@/components/plugin/PluginSourceCode"; import { PluginThreadChat } from "@/components/plugin/PluginThreadChat"; @@ -84,6 +85,7 @@ export const pluginSdkAppImplementation = installDeprecatedAliases( // plugins share one boundary. experimental_SourceCode: PluginSourceCode, experimental_Diff: PluginDiff, + experimental_ResponsiveDrawer: PluginResponsiveDrawer, // Experimental (see docs/api_to_audit.md): the sidebar thread-list data // plane, for plugins that replace the list itself. experimental_useSidebarThreads: useSidebarThreads, diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index d8a53eca1a..fbcb1085e1 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -1,6 +1,8 @@ import { useSyncExternalStore } from "react"; import type { ComposerCustomization, + ExperimentalChangesViewRegistration, + ExperimentalSidebarNavigationRegistration, PluginDiffRendererRegistration, PluginPendingInteractionRegistration, PluginFileOpenerRegistration, @@ -38,6 +40,8 @@ export interface PluginRegistrationSet { composerCustomizations?: readonly ComposerCustomization[]; pendingInteractions?: readonly PluginPendingInteractionRegistration[]; sidebarFooterActions: readonly PluginSidebarFooterActionRegistration[]; + /** Optional for bundles built before sidebar navigation replacement. */ + experimentalSidebarNavigations?: readonly ExperimentalSidebarNavigationRegistration[]; /** * Optional so a frontend bundle built against an older SDK — which never * calls `experimental_threadList` — still satisfies the set. @@ -53,6 +57,8 @@ export interface PluginRegistrationSet { sourceCodeRenderers?: readonly PluginSourceCodeRendererRegistration[]; /** Optional for the same reason as `sourceCodeRenderers`. */ diffRenderers?: readonly PluginDiffRendererRegistration[]; + /** Optional for bundles built before the whole Changes replacement. */ + experimentalChangesViews?: readonly ExperimentalChangesViewRegistration[]; messageDirectives: readonly PluginMessageDirectiveRegistration[]; messageActions?: readonly PluginMessageActionRegistration[]; /** Optional for the same reason as `threadLists`: bundles built earlier. */ @@ -90,6 +96,8 @@ export interface PluginPendingInteractionSlot extends PluginPendingInteractionRegistration, PluginSlotBase {} export interface PluginSidebarFooterActionSlot extends PluginSidebarFooterActionRegistration, PluginSlotBase {} +export interface ExperimentalSidebarNavigationSlot + extends ExperimentalSidebarNavigationRegistration, PluginSlotBase {} export interface PluginThreadListSlot extends PluginThreadListRegistration, PluginSlotBase {} interface PluginThreadHeaderActionSlot @@ -100,6 +108,8 @@ export interface PluginSourceCodeRendererSlot extends PluginSourceCodeRendererRegistration, PluginSlotBase {} export interface PluginDiffRendererSlot extends PluginDiffRendererRegistration, PluginSlotBase {} +export interface ExperimentalChangesViewSlot + extends ExperimentalChangesViewRegistration, PluginSlotBase {} export interface PluginMessageDirectiveSlot extends PluginMessageDirectiveRegistration, PluginSlotBase {} export interface PluginMessageActionSlot @@ -121,11 +131,13 @@ export interface PluginSlotSnapshot { composerCustomizations: readonly PluginComposerCustomizationSlot[]; pendingInteractions: readonly PluginPendingInteractionSlot[]; sidebarFooterActions: readonly PluginSidebarFooterActionSlot[]; + experimentalSidebarNavigations: readonly ExperimentalSidebarNavigationSlot[]; threadLists: readonly PluginThreadListSlot[]; threadHeaderActions: readonly PluginThreadHeaderActionSlot[]; fileOpeners: readonly PluginFileOpenerSlot[]; sourceCodeRenderers: readonly PluginSourceCodeRendererSlot[]; diffRenderers: readonly PluginDiffRendererSlot[]; + experimentalChangesViews: readonly ExperimentalChangesViewSlot[]; messageDirectives: readonly PluginMessageDirectiveSlot[]; messageActions: readonly PluginMessageActionSlot[]; commandPaletteActions: readonly PluginCommandPaletteActionSlot[]; @@ -142,11 +154,13 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { composerCustomizations: [], pendingInteractions: [], sidebarFooterActions: [], + experimentalSidebarNavigations: [], threadLists: [], threadHeaderActions: [], fileOpeners: [], sourceCodeRenderers: [], diffRenderers: [], + experimentalChangesViews: [], messageDirectives: [], messageActions: [], commandPaletteActions: [], @@ -170,11 +184,13 @@ const SLOT_KINDS: readonly SlotKind[] = [ "composerCustomizations", "pendingInteractions", "sidebarFooterActions", + "experimentalSidebarNavigations", "threadLists", "threadHeaderActions", "fileOpeners", "sourceCodeRenderers", "diffRenderers", + "experimentalChangesViews", "messageDirectives", "messageActions", "commandPaletteActions", @@ -219,11 +235,13 @@ function flattenRegistrations( composerCustomizations: stamp(set.composerCustomizations), pendingInteractions: stamp(set.pendingInteractions), sidebarFooterActions: stamp(set.sidebarFooterActions), + experimentalSidebarNavigations: stamp(set.experimentalSidebarNavigations), threadLists: stamp(set.threadLists), threadHeaderActions: stamp(set.threadHeaderActions), fileOpeners: stamp(set.fileOpeners), sourceCodeRenderers: stamp(set.sourceCodeRenderers), diffRenderers: stamp(set.diffRenderers), + experimentalChangesViews: stamp(set.experimentalChangesViews), messageDirectives: stamp(set.messageDirectives), messageActions: stamp(set.messageActions), commandPaletteActions: stamp(set.commandPaletteActions), diff --git a/apps/app/src/views/SettingsView.stories.tsx b/apps/app/src/views/SettingsView.stories.tsx index 8b31b226f7..b36aa95a6e 100644 --- a/apps/app/src/views/SettingsView.stories.tsx +++ b/apps/app/src/views/SettingsView.stories.tsx @@ -4,6 +4,7 @@ import { defaultAppTheme, defaultExperiments, type AppTheme, + type ComposerEscapeBehavior, type Experiments, type Host, defaultAppSettings, @@ -205,6 +206,8 @@ function useSettingsStoryState() { const [richTextEditing, setRichTextEditing] = useState(false); const [steerActiveThreadOnEnter, setSteerActiveThreadOnEnter] = useState(false); + const [composerEscapeBehavior, setComposerEscapeBehavior] = + useState("blur"); const [streamerMode, setStreamerMode] = useState(false); const [showUnhandledProviderEvents, setShowUnhandledProviderEvents] = useState(false); @@ -219,6 +222,7 @@ function useSettingsStoryState() { return { appearance, + composerEscapeBehavior, directoryTargetId, experiments, fileTargetId, @@ -231,6 +235,7 @@ function useSettingsStoryState() { streamerMode, showUnhandledProviderEvents, setAppearance, + setComposerEscapeBehavior, setDirectoryTargetId, setExperiments, setFileTargetId, @@ -274,10 +279,13 @@ function GeneralSettingsStory({ <> void; onRichTextEditingChange: (enabled: boolean) => void; onSteerActiveThreadOnEnterChange: (enabled: boolean) => void; + onComposerEscapeBehaviorChange: (value: ComposerEscapeBehavior) => void; onStreamerModeChange: (enabled: boolean) => void; openLinksInAppBrowser: boolean; rewriteLocalhostLinks: boolean; richTextEditing: boolean; steerActiveThreadOnEnter: boolean; steerActiveThreadOnEnterDisabled: boolean; + composerEscapeBehavior: ComposerEscapeBehavior; + composerEscapeBehaviorDisabled: boolean; streamerMode: boolean; streamerModeDisabled: boolean; } @@ -539,6 +544,19 @@ const UNHANDLED_PROVIDER_EVENTS_SETTING_LABEL = "Show unhandled provider events"; const STEER_ACTIVE_THREAD_ON_ENTER_SETTING_LABEL = "Steer running threads on Enter"; +const COMPOSER_ESCAPE_BEHAVIOR_SETTING_LABEL = "Escape in composer"; +const COMPOSER_ESCAPE_BEHAVIOR_OPTIONS = [ + { value: "blur", label: "Blur composer" }, + { value: "stop-running-thread", label: "Stop running thread" }, +] as const satisfies readonly { + value: ComposerEscapeBehavior; + label: string; +}[]; +const COMPOSER_ESCAPE_BEHAVIOR_LABELS: Record = + { + blur: "Blur composer", + "stop-running-thread": "Stop running thread", + }; const STREAMER_MODE_SETTING_LABEL = "Streamer mode"; export function AppearanceSettingsSection({ @@ -556,6 +574,7 @@ export function AppearanceSettingsSection({ return (
+ @@ -703,12 +722,15 @@ export function GeneralSettingsSection({ onRewriteLocalhostLinksChange, onRichTextEditingChange, onSteerActiveThreadOnEnterChange, + onComposerEscapeBehaviorChange, onStreamerModeChange, openLinksInAppBrowser, rewriteLocalhostLinks, richTextEditing, steerActiveThreadOnEnter, steerActiveThreadOnEnterDisabled, + composerEscapeBehavior, + composerEscapeBehaviorDisabled, streamerMode, streamerModeDisabled, }: GeneralSettingsSectionProps) { @@ -745,6 +767,47 @@ export function GeneralSettingsSection({ /> + + + + + + + {COMPOSER_ESCAPE_BEHAVIOR_OPTIONS.map((option) => ( + onComposerEscapeBehaviorChange(option.value)} + > + {option.label} + + + ))} + + + + {desktopBrowserAvailable ? ( + updateGeneralSettingsMutation.mutate({ + ...generalSettings, + composerEscapeBehavior, + }) + } streamerMode={generalSettings.streamerMode} streamerModeDisabled={ systemConfigQuery.data === undefined || diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 6d4844a179..e17d3da0bd 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -135,6 +135,7 @@ import { useGitDiffPanel } from "@/components/secondary-panel/git-diff/useGitDif import { createGitDiffFixedTabDestination, GIT_DIFF_FIXED_TAB_REFERENCE, + handleGitDiffShortcut, } from "@/components/secondary-panel/git-diff/git-diff-fixed-tab-navigation"; import { createThreadInfoFixedTabDestination, @@ -513,7 +514,7 @@ export function ThreadDetailView(props: ThreadDetailViewProps) { function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { const { projectId, threadId } = props; - const { isFocused, navigateInPane, onRequestClose, isBoundedPane } = + const { paneId, isFocused, navigateInPane, onRequestClose, isBoundedPane } = usePaneContext(); const navigate = useNavigate(); useFixedPanelTabsStorageMaintenance(); @@ -1248,7 +1249,6 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { const environmentMergeBaseBranch = resolveEnvironmentMergeBaseBranch(environment); const { - clearPendingGitDiffIntent, closeThreadSecondaryPanel, isLoadingMergeBaseBranchOptions, mergeBaseBranchOptions, @@ -1256,8 +1256,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { openCommitDiff: openPersistedCommitDiff, openDiffFile: openPersistedDiffFile, openThreadDiffPanel: openPersistedDiffPanel, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, + pendingGitDiffTarget, requestedMergeBaseBranch, selectedMergeBaseBranch, selectedMergeBaseBranchRef, @@ -1728,17 +1727,16 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { if (!isFocused) return false; return handleCloseWindowRequest(); }); - useAppCommandHandler("diff.toggle", () => { - if (!isFocused || !canUseGitUi) { - return false; - } - if (isSecondaryPanelOpen && activeFixedSecondaryTab?.kind === "git-diff") { - closeSecondaryPanel(); - } else { - openSecondaryPanelDiffPanel(); - } - return true; - }); + useAppCommandHandler("diff.toggle", () => + handleGitDiffShortcut({ + close: closeSecondaryPanel, + eligible: canUseGitUi, + isActive: activeFixedSecondaryTab?.kind === "git-diff", + isFocused, + isOpen: isSecondaryPanelOpen, + open: openSecondaryPanelDiffPanel, + }), + ); useEffect(() => { if (!isFocused) { return; @@ -2966,6 +2964,8 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { canUseGitUi, gitDiffTabStatus, environmentId: thread.environmentId ?? undefined, + threadId: thread.id, + changesViewInstanceId: paneId, workspaceRootPath: environment?.path, tabs: panelTabs, fixedTabs: secondaryPanelFixedTabs, @@ -2974,7 +2974,6 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { isOpen: isSecondaryPanelOpen, onClose: closeSecondaryPanel, onCollapse: closeSecondaryPanel, - onClearPendingGitDiffIntent: clearPendingGitDiffIntent, onOpenFileInEditor: handleOpenFileInEditor, onTabReorder: reorderTab, onOpenNewTab: handleOpenNewTab, @@ -2983,8 +2982,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { }, onOpenFilePreview: handleOpenFilePreview, onSelectionAddToChat: handleSelectionAddToChat, - pendingGitDiffCommitSha, - pendingGitDiffScrollPath, + pendingGitDiffTarget, requestedMergeBaseBranch, onPanelFocus: touchFixedPanelTabsState, }} diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index 7cc6ebe6e6..e4e075228d 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md @@ -1556,6 +1556,11 @@ export default definePluginApp((app) => { run: ({ openSettings }) => openSettings(), }); app.slots.messageDirective({ id: "inline-vis", component: InlineVis }); + app.slots.experimental_sidebarNavigation({ + id: "workspace-nav", + title: "Workspace navigation", + component: WorkspaceNavigation, + }); app.slots.experimental_threadList({ id: "inbox", title: "Inbox", @@ -1592,6 +1597,63 @@ A common pairing with a replaced sidebar: hide child threads from the list and surface them here instead, filtering `experimental_useSidebarThreads()` by `parentThreadId === threadId`. +### Replacing sidebar navigation controls + +`app.slots.experimental_sidebarNavigation` lets one selected plugin arrange New +thread, Search threads, Extensions, and registered plugin destinations. BB +keeps ownership of the persistent drawer, active search field and query, +thread-list mount, footer, resize handle, and hidden-body shortcut rules. +The plugin receives semantic items, not routes or host components. + +```tsx +function WorkspaceNavigation({ + activeItemId, + experimental_Original: Original, + experimental_activate, + items, +}: ExperimentalSidebarNavigationProps) { + const useNativeNavigation = useMyPluginSetting(); + if (useNativeNavigation) return ; + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} +``` + +Call `experimental_activate` for every item instead of constructing routes. +Spread `experimental_splitProps` onto the same interactive element so dragging +out of the sidebar keeps BB's split placement behavior. Pass the modifier state +through `openInSplit` for Command-click and Control-click. Search activation +mounts BB's combobox and sends its query to the current thread list. Normal, +modifier-click, and drag navigation close the compact drawer after they open a +destination. + +The choice is client-local under **Settings → Appearance → Navigation**. An +unavailable provider falls back without clearing the choice. A crash restores +only BB's navigation controls, so the retained thread list and footer do not +remount. `experimental_Original` delegates to those same native controls without +running replacement resolution again. The complete fixture is +`examples/plugins/replacement-lab-alpha`. + ### Replacing the sidebar thread list `app.slots.experimental_threadList` is the one **exclusive** slot: only one @@ -1987,6 +2049,43 @@ Original }`. `experimental_fullFileContents` is either Experimental: see `docs/api_to_audit.md`. +- `experimental_changesView` → + `{ threadId, environmentId, experimental_target, experimental_Original }` — + replaces the complete fixed Changes toolbar and virtualized file body. It + does not replace individual diffs. The host keeps the fixed tab, its route, + keyboard shortcut, split placement, and target state. Registration: + `{ id, title, description?, component }`. The slot is exclusive and uses the + same Automatic, built-in, or named-provider selector as the other replacement + slots. Users choose a provider under **Settings → Appearance → Changes**. + `experimental_target` is `null` for an ordinary open. A targeted open supplies + `{ sequence, target, clear }`, where `target` is either + `{ kind: "file", path }` or `{ kind: "commit", sha }`. `sequence` changes + when the same target opens again. Call `clear()` after handling it. + + Each app pane receives its own props and component instance. If one instance + throws, only that pane renders BB's Changes view; other panes keep the plugin. + Render `experimental_Original` to delegate without resolving + `experimental_changesView` again. It includes BB's toolbar and virtualized + body, and a global `experimental_diffRenderer` still applies to every text + diff inside it: + + ```tsx + app.slots.experimental_changesView({ + id: "review", + title: "Review Changes", + component: ({ experimental_target, experimental_Original: Original }) => + experimental_target?.target.kind === "commit" ? ( + + ) : ( + + ), + }); + ``` + + Reference fixtures: `examples/plugins/replacement-lab-alpha` and + `examples/plugins/replacement-lab-beta`. Experimental: see + `docs/api_to_audit.md`. + - `messageDirective` → `{ attributes, source, message, openWorkspaceFile }` — register a leaf assistant-message directive. Registration: @@ -2183,6 +2282,11 @@ routing?, allowProviderChange?, align?, disabled?, className? }`, where `routing drives language detection, `overflow` is `"scroll"` (default) or `"wrap"`, and `highlightedLines` is a 1-based inclusive `{ start, end }` (default null). bb owns syntax highlighting, gutters, and the live code theme. +- `experimental_ResponsiveDrawer` — bb's persistent non-modal bottom drawer. + Props: `{ open, onOpenChange, title, children, contentClassName? }`. It starts + its transform before realizing content, retains content after first open, + and never applies `inert` or `aria-hidden` to the app root. Alias it to an + uppercase name in JSX. - `experimental_Diff` — bb's diff viewer. Props: `{ patch, path, view?, overflow?, showLineNumbers?, experimental_fullFileContents?, className? }` — 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 9247a9f7c7..dd3e137744 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -4,6 +4,8 @@ import { describe, expect, it } from "vitest"; import * as pluginSdkApp from "@get-bb/plugin-sdk/app"; import { type BbPluginApi, + type ExperimentalChangesViewProps, + type ExperimentalSidebarNavigationProps, type PluginAppBuilder, type PluginAppSlots, type PluginContentScriptContext, @@ -166,11 +168,13 @@ type SlotPropsByName = { experimental_newThreadPanelAction: PluginNewThreadPanelProps; pendingInteraction: PluginPendingInteractionProps; sidebarFooterAction: PluginSidebarFooterActionProps; + experimental_sidebarNavigation: ExperimentalSidebarNavigationProps; experimental_threadList: PluginThreadListProps; experimental_threadHeaderAction: PluginThreadHeaderActionProps; fileOpener: PluginFileOpenerProps; experimental_sourceCodeRenderer: PluginSourceCodeRendererProps; experimental_diffRenderer: PluginDiffRendererProps; + experimental_changesView: ExperimentalChangesViewProps; messageDirective: PluginMessageDirectiveProps; messageAction: PluginMessageActionContext; commandPaletteAction: PluginCommandPaletteActionContext; @@ -237,6 +241,13 @@ const FRONTEND_SLOT_PROP_FIELDS = { experimental_newThreadPanelAction: ["projectId", "params"], pendingInteraction: ["interaction", "submit", "cancel"], sidebarFooterAction: [], + experimental_sidebarNavigation: [ + "items", + "activeItemId", + "isCompactViewport", + "experimental_activate", + "experimental_Original", + ], experimental_threadList: [ "activeThreadId", "activeProjectId", @@ -270,6 +281,12 @@ const FRONTEND_SLOT_PROP_FIELDS = { "Original", "experimental_Original", ], + experimental_changesView: [ + "threadId", + "environmentId", + "experimental_target", + "experimental_Original", + ], messageDirective: ["attributes", "source", "message", "openWorkspaceFile"], messageAction: ["threadId", "message", "selectedText", "openPanel"], commandPaletteAction: ["threadId", "projectId", "openPanel"], diff --git a/docs/api_to_audit.md b/docs/api_to_audit.md index 5ce0044610..543c9e6f15 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -975,6 +975,18 @@ bridge as provider-scoped static options. Core does not interpret its keys. installed-only provider, and that targeted requests may continue resolving a registered provider even while discovery says it is absent. +## Persistent plugin responsive drawer (`experimental_ResponsiveDrawer`, `ExperimentalResponsiveDrawerProps`) + +**What it does.** Gives a plugin the host's persistent, non-modal bottom drawer. +The transform begins before content realization; content mounts after two +animation frames with a timeout fallback and remains mounted after first open. +The backdrop and focus stack contain interaction without applying `inert` or +`aria-hidden` to the app root. + +**Audit before stabilizing.** Confirm title-only labeling is sufficient, whether +plugins need animation-end or realization callbacks, and whether arbitrary +content-height control should remain a class-name escape hatch. + ## `@get-bb/plugin-sdk/provider-bridge` (the provider-bridge authoring surface) **Kept experimental (2026-08-22).** `experimental_defineProviderBridge` / `experimental_apiVersion` are an artifact↔daemon contract (the bootstrap refuses anything but version 1 by name), and the deprecation window between independently-updating artifacts and daemons (item 4) is undecided. @@ -1515,6 +1527,47 @@ Before stabilization, audit: re-litigate that in the stabilization audit; audit only whether the two _contexts_ should merge. +## `app.slots.experimental_sidebarNavigation` (`@get-bb/plugin-sdk/app`) + +**What it does.** Replaces only BB's bounded, inactive sidebar navigation +controls: New thread, Search threads, Extensions, and current `navPanel` +destinations. The plugin receives semantic items, split-drag bindings, and one +host activation callback; it receives no routes, router, drawer control, or +host React elements. BB retains the persistent responsive drawer, active search +combobox and query, thread-list mount and lifecycle, footer, resize handle, and the +rule that a retained hidden app body cannot claim thread shortcuts. + +Navigation uses the same Automatic, BB, or named-provider client preference as +other replacements. `experimental_Original` bypasses replacement resolution. +A crash swaps only the bounded controls back to BB and toasts once, leaving the +retained thread list and footer mounted. Activating Search swaps in BB's field; +the existing list receives the host query. Native search results preserve +ordinary navigation, modifier-click splitting, and drag-to-split. Destination +activation closes the compact drawer. + +**Audit before stabilizing.** + +1. **Boundary.** Verify external replacements can express useful navigation + without control of the drawer, search field, thread list, footer, resize + handle, or hidden-body shortcuts. Do not widen by passing those host nodes + through plugin props. +2. **Semantic item schema.** Confirm the four action variants and semantic icon + identity are sufficient. Keep routes and host components private. +3. **Split contract.** Audit `experimental_splitProps` plus + `experimental_activate(..., { openInSplit })` against pointer, keyboard, + modifier-click, pane-cap, already-open-pane, and compact fallback behavior. +4. **Search lifecycle.** Confirm unmounting the replacement while the host + search field is active is acceptable, and that native and replaced thread + lists both filter from the same host query. +5. **Crash and delegation.** Verify `experimental_Original` and crash fallback never + recurse through resolution or remount retained thread-list/footer state. +6. **Arbitration.** Confirm Automatic remains the right default when several + navigation replacements are enabled and unavailable pinned providers should + continue to fall back without clearing preference. +7. **Accessibility.** Validate third-party markup can preserve labels, + `aria-current`, shortcut metadata, disabled state, focus order, and the + host-owned combobox semantics. + ## `app.slots.experimental_threadList` (`@get-bb/plugin-sdk/app`) **Kept experimental (2026-08-22).** examples only; no shipped consumer has tested the arbitration/fallback model or the accessibility contract. @@ -1730,6 +1783,64 @@ is temporarily unavailable renders BB's renderer without erasing the pin. 5. **Two slots or one.** Confirm source and diff should stay separately replaceable rather than one "code renderer" registration. +## `app.slots.experimental_changesView` (`@get-bb/plugin-sdk/app`) + +**What it does.** Replaces the complete fixed Changes toolbar and virtualized +file body in each app pane. BB keeps ownership of the fixed tab, route, +keyboard shortcut, split placement, and pane-local target state. The props +identify the pane's thread and environment. `experimental_target` supplies a +file path or commit SHA plus a sequence and `clear()` callback when core routing +targets that pane. + +The slot uses the exclusive replacement selector. Automatic mode picks the +first provider in deterministic slot order. Appearance settings can pin BB or +a named provider on each client. A disabled, missing, or crashing provider +falls back to BB without clearing the pin. Crash state includes the app pane's +instance identity, so one pane can fall back while another keeps the plugin. +`experimental_Original` renders BB's complete Changes owner without resolving +this slot again. The global `experimental_diffRenderer` still resolves inside +its file bodies. + +**Audit before stabilizing.** + +1. Confirm `threadId`, `environmentId`, and the routed target are enough for a + useful replacement. Plugins currently load their own change data through + backend RPC. +2. Confirm target acknowledgement belongs in component props. In particular, + audit whether a plugin that never calls `clear()` needs a host timeout or a + default acknowledgement policy. +3. Confirm the target union should remain limited to file and commit opens. + The native selection picker also represents all, committed, and uncommitted + slices, but those are owner-local choices rather than routed intents. +4. Verify pane identity remains the correct crash boundary if BB later permits + the same pane to host multiple simultaneous Changes instances. +5. Confirm the per-client Automatic, built-in, or named-provider selector is + appropriate for a whole product view. +6. Audit whether `experimental_Original` should remain a bound no-props + component if plugins need to decorate the native toolbar or body separately. + +## `PluginSourceCodeRendererProps.experimental_Original` / `PluginDiffRendererProps.experimental_Original` (`@get-bb/plugin-sdk/app`) + +**What it does.** Supplies a renderer replacement with BB's renderer bound to +the current render. Rendering it delegates without re-entering replacement +resolution; the host renders the same component as the crash fallback. BB's +renderers are behind `lazy()`, so a replacement that never delegates never +downloads them. + +**Audit before stabilizing.** + +1. Confirm a no-props bound component stays the right delegation contract as + the host-only inputs (pre-parsed files, selection-to-chat) grow. +2. Verify delegation preserves everything the owner path does on BB's own + surfaces — context expansion, line selection, highlighted-line scrolling — + when the replacement delegates from inside a first-party card. +3. Confirm the lazy boundary stays lazy: a replacement that never delegates + must not pull BB's renderer chunk, and the Suspense fallback must not + flash on the owner path. +4. Decide whether this field should stabilize together with the shared + replacement primitive that `PluginThreadListProps` and + `PluginFileOpenerProps` also use, rather than per surface. + ## `experimental_useSidebarThreads` / `experimental_useSidebarThreadActions` (`@get-bb/plugin-sdk/app`) **Kept experimental (2026-08-22).** zero consumers; items 4 (a paged/windowed read at 10k threads) and 5 (the draft indicator gap) are unresolvable without one and both change the contract. diff --git a/examples/plugins/replacement-lab-alpha/README.md b/examples/plugins/replacement-lab-alpha/README.md index 17d2865d8c..443edb13ec 100644 --- a/examples/plugins/replacement-lab-alpha/README.md +++ b/examples/plugins/replacement-lab-alpha/README.md @@ -1,13 +1,23 @@ # Replacement Lab Install this plugin together with `replacement-lab-beta` to exercise BB's -exclusive replacement behavior. Both register a thread list and a Markdown -file opener. +exclusive replacement behavior. Both register a thread list, a complete +Changes view, and a Markdown file opener. Alpha also registers a sidebar +navigation replacement; Beta also registers a global diff renderer. - Automatic mode selects Alpha first because plugin IDs are deterministic. - Settings can pin BB, Alpha, or Beta. +- Alpha's navigation grid exercises host Search, modifier-click and + drag-to-split bindings, plugin destinations, `experimental_Original`, and + crash fallback while the host keeps the drawer, search field, thread list, + footer, and shortcuts. - **Embed BB original** exercises the instance-bound `Original` renderer. -- **Crash** exercises the owner crash fallback. +- **Crash** exercises the owner crash fallback. Open Changes in two app panes + to verify that a crash falls back only the pane that crashed. +- A file or commit opened through BB's fixed Changes route appears as the + pane-local target. **Clear target** acknowledges it. +- Embed BB's Changes view while Beta's global diff renderer is active to verify + that the renderer still applies to the native virtualized body. - Disabling Alpha while Automatic is selected reveals Beta. The fixtures are intentionally diagnostic rather than useful thread lists or diff --git a/examples/plugins/replacement-lab-alpha/app.tsx b/examples/plugins/replacement-lab-alpha/app.tsx index a228499971..01cca48eb9 100644 --- a/examples/plugins/replacement-lab-alpha/app.tsx +++ b/examples/plugins/replacement-lab-alpha/app.tsx @@ -1,12 +1,68 @@ import { useState } from "react"; import { definePluginApp, + type ExperimentalChangesViewProps, + type ExperimentalSidebarNavigationProps, type PluginFileOpenerProps, type PluginThreadListProps, } from "@get-bb/plugin-sdk/app"; const LABEL = "Alpha"; +function AlphaSidebarNavigation({ + activeItemId, + experimental_Original: Original, + experimental_activate, + items, +}: ExperimentalSidebarNavigationProps) { + const [embedOriginal, setEmbedOriginal] = useState(false); + const [shouldCrash, setShouldCrash] = useState(false); + if (shouldCrash) throw new Error("Alpha sidebar-navigation test crash"); + if (embedOriginal) return ; + + return ( +
+
+ Alpha · Navigation + + +
+
+ {items.map((item) => ( + + ))} +
+
+ ); +} + function AlphaThreadList({ activeProjectId, activeThreadId, @@ -51,6 +107,62 @@ function AlphaThreadList({ ); } +function AlphaChangesView({ + environmentId, + experimental_Original: Original, + experimental_target, + threadId, +}: ExperimentalChangesViewProps) { + const [embedOriginal, setEmbedOriginal] = useState(false); + const [shouldCrash, setShouldCrash] = useState(false); + if (shouldCrash) throw new Error("Alpha Changes-view test crash"); + + return ( +
+ setShouldCrash(true)} + /> + {embedOriginal ? ( +
+ +
+ ) : ( +
+

+ Alpha owns the complete Changes toolbar and body. +

+
+
Thread
+
{threadId}
+
Environment
+
{environmentId}
+
Target
+
+ {experimental_target === null + ? "none" + : experimental_target.target.kind === "file" + ? `file:${experimental_target.target.path}` + : `commit:${experimental_target.target.sha}`} +
+
+ {experimental_target === null ? null : ( + + )} +
+ )} +
+ ); +} + function AlphaFileOpener({ Original, path, @@ -126,12 +238,24 @@ function LabHeader({ } export default definePluginApp((app) => { + app.slots.experimental_sidebarNavigation({ + id: "alpha-navigation", + title: "Replacement Lab Alpha", + description: "Test host-owned sidebar navigation replacement.", + component: AlphaSidebarNavigation, + }); app.slots.experimental_threadList({ id: "alpha-list", title: "Replacement Lab Alpha", description: "Test provider Alpha.", component: AlphaThreadList, }); + app.slots.experimental_changesView({ + id: "alpha-changes", + title: "Replacement Lab Alpha", + description: "Test whole-Changes provider Alpha.", + component: AlphaChangesView, + }); app.slots.fileOpener({ id: "alpha-markdown", title: "Alpha Markdown", diff --git a/examples/plugins/replacement-lab-alpha/package.json b/examples/plugins/replacement-lab-alpha/package.json index e7a7614e23..2c9e6a69a9 100644 --- a/examples/plugins/replacement-lab-alpha/package.json +++ b/examples/plugins/replacement-lab-alpha/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "engines": { - "bbPluginSdk": ">=0.4.3" + "bbPluginSdk": ">=0.4.17" }, "bb": { "name": "Replacement Lab Alpha", - "description": "Exercises thread-list and file-opener replacement behavior.", + "description": "Exercises sidebar-navigation, thread-list, Changes-view, and file-opener replacement behavior.", "branding": { "icon": "Code" }, diff --git a/examples/plugins/replacement-lab-beta/README.md b/examples/plugins/replacement-lab-beta/README.md index 39855ec9b8..56f24b73ae 100644 --- a/examples/plugins/replacement-lab-beta/README.md +++ b/examples/plugins/replacement-lab-beta/README.md @@ -2,4 +2,6 @@ The second provider for `replacement-lab-alpha`. Install both fixtures, then use Automatic/provider selection, **Embed BB original**, **Crash**, and plugin -enable/disable to exercise precedence and fallback behavior. +enable/disable to exercise precedence and fallback behavior. Beta's global diff +renderer labels native file diffs, including diffs inside an embedded BB +Changes view. diff --git a/examples/plugins/replacement-lab-beta/app.tsx b/examples/plugins/replacement-lab-beta/app.tsx index bf3f727368..578c045b5d 100644 --- a/examples/plugins/replacement-lab-beta/app.tsx +++ b/examples/plugins/replacement-lab-beta/app.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import { definePluginApp, + type ExperimentalChangesViewProps, type PluginFileOpenerProps, type PluginThreadListProps, } from "@get-bb/plugin-sdk/app"; @@ -51,6 +52,62 @@ function BetaThreadList({ ); } +function BetaChangesView({ + environmentId, + experimental_Original: Original, + experimental_target, + threadId, +}: ExperimentalChangesViewProps) { + const [embedOriginal, setEmbedOriginal] = useState(false); + const [shouldCrash, setShouldCrash] = useState(false); + if (shouldCrash) throw new Error("Beta Changes-view test crash"); + + return ( +
+ setShouldCrash(true)} + /> + {embedOriginal ? ( +
+ +
+ ) : ( +
+

+ Beta owns the complete Changes toolbar and body. +

+
+
Thread
+
{threadId}
+
Environment
+
{environmentId}
+
Target
+
+ {experimental_target === null + ? "none" + : experimental_target.target.kind === "file" + ? `file:${experimental_target.target.path}` + : `commit:${experimental_target.target.sha}`} +
+
+ {experimental_target === null ? null : ( + + )} +
+ )} +
+ ); +} + function BetaFileOpener({ Original, path, @@ -132,6 +189,25 @@ export default definePluginApp((app) => { description: "Test provider Beta.", component: BetaThreadList, }); + app.slots.experimental_changesView({ + id: "beta-changes", + title: "Replacement Lab Beta", + description: "Test whole-Changes provider Beta.", + component: BetaChangesView, + }); + app.slots.experimental_diffRenderer({ + id: "beta-diff", + title: "Replacement Lab Beta diffs", + description: "Labels native diffs to verify Changes-view coexistence.", + component: ({ experimental_Original: Original, path }) => ( +
+
+ Beta global diff · {path} +
+ {Original ? : null} +
+ ), + }); app.slots.fileOpener({ id: "beta-markdown", title: "Beta Markdown", diff --git a/examples/plugins/replacement-lab-beta/package.json b/examples/plugins/replacement-lab-beta/package.json index 7058effb6d..c544cec37c 100644 --- a/examples/plugins/replacement-lab-beta/package.json +++ b/examples/plugins/replacement-lab-beta/package.json @@ -4,11 +4,11 @@ "private": true, "type": "module", "engines": { - "bbPluginSdk": ">=0.4.3" + "bbPluginSdk": ">=0.4.17" }, "bb": { "name": "Replacement Lab Beta", - "description": "A second provider for precedence and fallback testing.", + "description": "Tests Changes-view precedence and global diff-renderer coexistence.", "branding": { "icon": "Code" }, diff --git a/packages/domain/src/plugin-sdk-version.ts b/packages/domain/src/plugin-sdk-version.ts index 91a3bfe6f4..d0ff96ab38 100644 --- a/packages/domain/src/plugin-sdk-version.ts +++ b/packages/domain/src/plugin-sdk-version.ts @@ -16,7 +16,7 @@ // PLUGIN_SDK_MAJOR is 0, so the major-only artifact gate cannot distinguish // 0.x releases and is intentionally vacuous for them until a future 1.0. // Rebuildable artifacts still rebuild on the exact sdkVersion-differs trigger. -export const PLUGIN_SDK_VERSION = "0.4.18"; +export const PLUGIN_SDK_VERSION = "0.4.19"; /** Major of {@link PLUGIN_SDK_VERSION} — the plugin API compatibility number. */ export const PLUGIN_SDK_MAJOR = Number(PLUGIN_SDK_VERSION.split(".", 1)[0]); diff --git a/packages/plugin-sdk/package.json b/packages/plugin-sdk/package.json index 9b9171b282..79fb34b294 100644 --- a/packages/plugin-sdk/package.json +++ b/packages/plugin-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@get-bb/plugin-sdk", - "version": "0.4.18", + "version": "0.4.19", "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 d0d13120c5..2b6f4c564f 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -105,6 +105,71 @@ export interface PluginPendingInteractionProps { */ export interface PluginSidebarFooterActionProps {} +/** Display and accessibility metadata for a host-owned sidebar shortcut. */ +export interface ExperimentalSidebarNavigationShortcut { + label: string; + ariaKeyShortcuts: string; +} + +/** Host-owned behavior represented by one sidebar navigation item. */ +export type ExperimentalSidebarNavigationAction = + | { kind: "new-thread" } + | { kind: "search-threads" } + | { kind: "open-extensions" } + | { + kind: "open-plugin-panel"; + pluginId: string; + panelId: string; + }; + +/** Semantic icon identity for one sidebar navigation item. */ +export type ExperimentalSidebarNavigationIcon = + | { kind: "host"; name: "new-thread" | "search" | "extensions" } + | { kind: "plugin"; pluginId: string; icon: string | null }; + +/** One host-owned destination or action a plugin may arrange. */ +export interface ExperimentalSidebarNavigationItem { + /** Stable identity within the current replacement generation. */ + id: string; + label: string; + icon: ExperimentalSidebarNavigationIcon; + action: ExperimentalSidebarNavigationAction; + isDisabled: boolean; + shortcut: ExperimentalSidebarNavigationShortcut | null; + /** + * Spread onto the item's interactive element to preserve BB's drag-to-split + * gesture. Empty when the item cannot split or the viewport is compact. + */ + experimental_splitProps: { + onPointerDown?: (event: import("react").PointerEvent) => void; + }; +} + +/** How the host should activate a sidebar navigation item. */ +export interface ExperimentalSidebarNavigationActivationOptions { + /** Apply BB's existing modifier-click split placement rules. */ + openInSplit: boolean; +} + +/** Props passed to an `experimental_sidebarNavigation` component. */ +export interface ExperimentalSidebarNavigationProps { + /** Ordered semantic snapshot. A plugin may visually regroup or reorder it. */ + items: readonly ExperimentalSidebarNavigationItem[]; + /** Host-derived active item, or null when the route is outside this list. */ + activeItemId: string | null; + isCompactViewport: boolean; + /** Resolve and run a current host item by id. */ + experimental_activate( + itemId: string, + options: ExperimentalSidebarNavigationActivationOptions, + ): void; + /** + * BB's native navigation controls, bound to this sidebar. Rendering them + * delegates without resolving `experimental_sidebarNavigation` again. + */ + experimental_Original: ComponentType; +} + /** * Props passed to an `experimental_threadList` component — the sidebar's * scrolling thread area, replaced wholesale by one plugin. @@ -331,6 +396,39 @@ export interface PluginDiffRendererProps { experimental_Original?: ComponentType; } +/** A target routed to one pane's fixed Changes tab. */ +export type ExperimentalChangesViewTarget = + | { kind: "file"; path: string } + | { kind: "commit"; sha: string }; + +/** + * The current session target for one Changes-view instance. `sequence` + * changes when the same target is opened again. Call `clear` after handling + * the target so a later open can be distinguished from retained state. + */ +export interface ExperimentalChangesViewTargetState { + readonly sequence: number; + readonly target: ExperimentalChangesViewTarget; + clear(): void; +} + +/** Props passed to an `experimental_changesView` component. */ +export interface ExperimentalChangesViewProps { + /** Thread shown in this pane. */ + threadId: string; + /** Environment whose changes the pane owns. */ + environmentId: string; + /** A file or commit routed to this pane, or null for an ordinary open. */ + experimental_target: ExperimentalChangesViewTargetState | null; + /** + * BB's complete Changes toolbar and virtualized body, bound to this pane. + * Rendering it delegates without resolving `experimental_changesView` + * again. An active `experimental_diffRenderer` still applies to its file + * bodies. + */ + experimental_Original: ComponentType; +} + /** * Message context passed to a `messageDirective` component — the assistant * (or nested agent) message that contained the directive. @@ -890,6 +988,24 @@ export interface PluginSidebarThreadSplit { layout: { panes: readonly PluginSidebarSplitPane[] } | null; } +/** + * Replace BB's bounded sidebar navigation controls while BB retains the + * persistent drawer, host search field, thread-list mount, footer, resize + * handle, and hidden-body shortcut policy. + * + * This replacement receives semantic host actions rather than routes or host + * elements. Missing and crashing replacements fall back to native controls. + */ +export interface ExperimentalSidebarNavigationRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label shown in Appearance settings. */ + title: string; + /** Optional one-line description shown with the provider choice. */ + description?: string; + component: ComponentType; +} + /** * Replace the sidebar's thread list with a plugin component. * @@ -974,6 +1090,20 @@ export interface PluginDiffRendererRegistration { component: ComponentType; } +/** + * Replace the complete fixed Changes view. The fixed tab, routing, shortcut, + * split placement, and pane-local target state remain host-owned. + */ +export interface ExperimentalChangesViewRegistration { + /** Unique within the plugin; letters, digits, `-`, `_`. */ + id: string; + /** Label shown in Appearance settings. */ + title: string; + /** Optional one-line description shown with the provider choice. */ + description?: string; + component: ComponentType; +} + /** * Register a leaf message directive rendered inside assistant (and nested * agent) message Markdown. `id` is the directive name: `inline-vis` matches @@ -1208,6 +1338,14 @@ export interface PluginTimelineRendererRegistration { component: ComponentType; } +export interface ExperimentalResponsiveDrawerProps { + open: boolean; + onOpenChange(open: boolean): void; + title: string; + children: ReactNode; + contentClassName?: string; +} + // --------------------------------------------------------------------------- // definePluginApp // --------------------------------------------------------------------------- @@ -1233,6 +1371,14 @@ export interface PluginAppSlots { sidebarFooterAction( registration: PluginSidebarFooterActionRegistration, ): void; + /** + * Replace BB's bounded sidebar navigation controls while BB retains the + * surrounding drawer, search field, thread list, footer, and shortcuts. + * Experimental: see docs/api_to_audit.md. + */ + experimental_sidebarNavigation( + registration: ExperimentalSidebarNavigationRegistration, + ): void; /** * Replace the sidebar's thread list (see * {@link PluginThreadListRegistration}). Experimental: see @@ -1262,6 +1408,14 @@ export interface PluginAppSlots { * docs/api_to_audit.md. */ experimental_diffRenderer(registration: PluginDiffRendererRegistration): void; + /** + * Replace BB's complete Changes toolbar and body (see + * {@link ExperimentalChangesViewRegistration}). Experimental: see + * docs/api_to_audit.md. + */ + experimental_changesView( + registration: ExperimentalChangesViewRegistration, + ): void; messageDirective(registration: PluginMessageDirectiveRegistration): void; messageAction(registration: PluginMessageActionRegistration): void; /** @@ -2118,5 +2272,10 @@ export interface PluginSdkApp { * docs/api_to_audit.md. */ experimental_Diff: ComponentType; + /** + * Persistent non-modal responsive drawer; no inert or aria-hidden is applied + * to the app root. Experimental: see docs/api_to_audit.md. + */ + experimental_ResponsiveDrawer: ComponentType; useComposerView(): ComposerView; } diff --git a/packages/plugin-sdk/src/app.ts b/packages/plugin-sdk/src/app.ts index f7d4db4dbf..22da13e4cb 100644 --- a/packages/plugin-sdk/src/app.ts +++ b/packages/plugin-sdk/src/app.ts @@ -59,6 +59,8 @@ export const experimental_PermissionModePicker = // Host-owned code rendering (experimental — see docs/api_to_audit.md). export const experimental_SourceCode = runtime.experimental_SourceCode; export const experimental_Diff = runtime.experimental_Diff; +export const experimental_ResponsiveDrawer = + runtime.experimental_ResponsiveDrawer; export const useRpc = runtime.useRpc; export const useRealtime = runtime.useRealtime; export const useRealtimeConnectionState = runtime.useRealtimeConnectionState; diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 158a1b0d69..663394ac4d 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -1,5 +1,7 @@ import type { ComposerCustomization, + ExperimentalChangesViewRegistration, + ExperimentalSidebarNavigationRegistration, PluginAppDefinition, PluginContentScriptRegistration, PluginDiffRendererRegistration, @@ -91,11 +93,13 @@ export interface CollectedPluginAppRegistrations { composerCustomizations: ComposerCustomization[]; pendingInteractions: PluginPendingInteractionRegistration[]; sidebarFooterActions: PluginSidebarFooterActionRegistration[]; + experimentalSidebarNavigations: ExperimentalSidebarNavigationRegistration[]; threadLists: PluginThreadListRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; sourceCodeRenderers: PluginSourceCodeRendererRegistration[]; diffRenderers: PluginDiffRendererRegistration[]; + experimentalChangesViews: ExperimentalChangesViewRegistration[]; messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; commandPaletteActions: PluginCommandPaletteActionRegistration[]; @@ -124,11 +128,13 @@ export function collectPluginAppRegistrations( composerCustomizations: [], pendingInteractions: [], sidebarFooterActions: [], + experimentalSidebarNavigations: [], threadLists: [], threadHeaderActions: [], fileOpeners: [], sourceCodeRenderers: [], diffRenderers: [], + experimentalChangesViews: [], messageDirectives: [], messageActions: [], commandPaletteActions: [], @@ -145,11 +151,13 @@ export function collectPluginAppRegistrations( composerCustomization: new Set(), pendingInteraction: new Set(), sidebarFooterAction: new Set(), + sidebarNavigation: new Set(), threadList: new Set(), threadHeaderAction: new Set(), fileOpener: new Set(), sourceCodeRenderer: new Set(), diffRenderer: new Set(), + changesView: new Set(), messageDirective: new Set(), messageAction: new Set(), commandPaletteAction: new Set(), @@ -385,6 +393,22 @@ export function collectPluginAppRegistrations( run: registration.run, }); }, + experimental_sidebarNavigation(registration) { + const kind = "slots.experimental_sidebarNavigation"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.sidebarNavigation, id); + const description = requireOptionalString( + kind, + "description", + registration.description, + ); + collected.experimentalSidebarNavigations.push({ + id, + title: requireNonEmptyString(kind, "title", registration.title), + ...(description !== undefined ? { description } : {}), + component: requireComponent(kind, registration.component), + }); + }, experimental_threadList(registration) { const kind = "slots.experimental_threadList"; const id = requireSlotId(kind, registration?.id); @@ -468,6 +492,22 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_changesView(registration) { + const kind = "slots.experimental_changesView"; + const id = requireSlotId(kind, registration?.id); + requireUniqueId(kind, seenIds.changesView, id); + const description = requireOptionalString( + kind, + "description", + registration.description, + ); + collected.experimentalChangesViews.push({ + id, + title: requireNonEmptyString(kind, "title", registration.title), + ...(description !== undefined ? { description } : {}), + component: requireComponent(kind, registration.component), + }); + }, messageDirective(registration) { const kind = "slots.messageDirective"; const id = requireMessageDirectiveId(kind, registration?.id); diff --git a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx index 05fba37be0..778d364a5a 100644 --- a/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx +++ b/packages/plugin-sdk/src/testing/__tests__/app-harness.test.tsx @@ -466,6 +466,86 @@ const app = await loadPluginApp( ); describe("loadPluginApp", () => { + it("captures and validates sidebar-navigation replacement registrations", async () => { + const component = () => null; + const captured = await loadPluginApp( + definePluginApp((builder) => { + builder.slots.experimental_sidebarNavigation({ + id: "workspace", + title: "Workspace navigation", + description: "A compact destination grid.", + component, + }); + }), + ); + + expect(captured.experimentalSidebarNavigations).toEqual([ + { + id: "workspace", + title: "Workspace navigation", + description: "A compact destination grid.", + component, + }, + ]); + await expect( + loadPluginApp( + definePluginApp((builder) => { + builder.slots.experimental_sidebarNavigation({ + id: "workspace", + title: "One", + component, + }); + builder.slots.experimental_sidebarNavigation({ + id: "workspace", + title: "Two", + component, + }); + }), + ), + ).rejects.toThrow( + 'slots.experimental_sidebarNavigation: duplicate id "workspace"', + ); + }); + + it("captures and validates whole-Changes replacement registrations", async () => { + const component = () => null; + const captured = await loadPluginApp( + definePluginApp((builder) => { + builder.slots.experimental_changesView({ + id: "review", + title: "Review Changes", + description: "A complete Changes view.", + component, + }); + }), + ); + + expect(captured.experimentalChangesViews).toEqual([ + { + id: "review", + title: "Review Changes", + description: "A complete Changes view.", + component, + }, + ]); + await expect( + loadPluginApp( + definePluginApp((builder) => { + builder.slots.experimental_changesView({ + id: "review", + title: "One", + component, + }); + builder.slots.experimental_changesView({ + id: "review", + title: "Two", + component, + }); + }), + ), + ).rejects.toThrow('slots.experimental_changesView: duplicate id "review"'); + }); + it("captures and validates New thread panel action registrations", async () => { const run = () => {}; const captured = await loadPluginApp( diff --git a/packages/plugin-sdk/src/testing/app.tsx b/packages/plugin-sdk/src/testing/app.tsx index a52b325f68..dcab5e66eb 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -26,11 +26,14 @@ import { type PluginComposerScope, type PluginComposerTextEffect, type PluginComposerThreadRowStatus, + type ExperimentalChangesViewRegistration, type PluginFileOpenerRegistration, type PluginHomepageSectionRegistration, type PluginMessageActionRegistration, type PluginMessageDirectiveRegistration, type PluginDiffRendererRegistration, + type ExperimentalResponsiveDrawerProps, + type ExperimentalSidebarNavigationRegistration, type PluginNavPanelRegistration, type PluginNewThreadPanelActionRegistration, type PluginPendingInteractionRegistration, @@ -667,6 +670,18 @@ function TestSourceCode({ ); } +function TestResponsiveDrawer({ + open, + title, + children, +}: ExperimentalResponsiveDrawerProps) { + return open ? ( +
+ {children} +
+ ) : null; +} + /** * Stand-in for the host-owned diff viewer: emits the raw patch in a * recognizable wrapper carrying the resolved presentation. @@ -799,6 +814,7 @@ const testPluginSdkApp = { experimental_PermissionModePicker: TestPermissionModePicker, experimental_SourceCode: TestSourceCode, experimental_Diff: TestDiff, + experimental_ResponsiveDrawer: TestResponsiveDrawer, experimental_useSidebarThreads(): PluginSidebarThreadsState { return useSlotEnv("experimental_useSidebarThreads").sidebarThreads; }, @@ -889,11 +905,13 @@ export interface CapturedPluginApp { composerCustomizations: ComposerCustomization[]; pendingInteractions: PluginPendingInteractionRegistration[]; sidebarFooterActions: PluginSidebarFooterActionRegistration[]; + experimentalSidebarNavigations: ExperimentalSidebarNavigationRegistration[]; threadLists: PluginThreadListRegistration[]; threadHeaderActions: PluginThreadHeaderActionRegistration[]; fileOpeners: PluginFileOpenerRegistration[]; sourceCodeRenderers: PluginSourceCodeRendererRegistration[]; diffRenderers: PluginDiffRendererRegistration[]; + experimentalChangesViews: ExperimentalChangesViewRegistration[]; messageDirectives: PluginMessageDirectiveRegistration[]; messageActions: PluginMessageActionRegistration[]; providerIcons: PluginProviderIconRegistration[];