diff --git a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
index 3dfb4e89ab..a388028253 100644
--- a/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.plugin-panel-header.test.tsx
@@ -210,17 +210,22 @@ describe("AppLayout plugin panel header", () => {
).toBe(true);
});
- it("shows the fixed left trigger only while the compact right panel is closed", () => {
+ it("keeps the fixed left trigger above compact panels", () => {
viewportState.compact = true;
renderPluginPanelRoute();
const trigger = screen.getByTestId("app-sidebar-trigger-overlay");
- expect(trigger.style.zIndex).toBe(String(APP_OVERLAY_LAYER.sidebarTrigger));
+ expect(trigger.style.zIndex).toBe(
+ String(APP_OVERLAY_LAYER.compactSidebarTrigger),
+ );
+ expect(Number(trigger.style.zIndex)).toBeGreaterThan(
+ APP_OVERLAY_LAYER.secondaryPanelFullPage,
+ );
act(() => setCompactSecondaryPanelPresentation("shelf"));
- expect(screen.queryByTestId("app-sidebar-trigger-overlay")).toBeNull();
+ expect(screen.getByTestId("app-sidebar-trigger-overlay")).toBe(trigger);
act(() => setCompactSecondaryPanelPresentation("full"));
- expect(screen.queryByTestId("app-sidebar-trigger-overlay")).toBeNull();
+ expect(screen.getByTestId("app-sidebar-trigger-overlay")).toBe(trigger);
act(() => setCompactSecondaryPanelPresentation("closed"));
expect(screen.getByTestId("app-sidebar-trigger-overlay")).not.toBeNull();
diff --git a/apps/app/src/components/layout/AppLayout.test.tsx b/apps/app/src/components/layout/AppLayout.test.tsx
index 4ee73f053b..d058bb073d 100644
--- a/apps/app/src/components/layout/AppLayout.test.tsx
+++ b/apps/app/src/components/layout/AppLayout.test.tsx
@@ -15,6 +15,8 @@ import { Link, MemoryRouter, useLocation } from "react-router-dom";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppCommandProvider } from "@/components/commands/AppCommandProvider";
import { AppLayout } from "./AppLayout";
+import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport";
+import { setCompactSecondaryPanelPresentation } from "@/components/ui/secondary-panel-shelf-visibility";
const SIDEBAR_WIDTH_STORAGE_KEY = "bb.sidebar.width";
const APP_ROUTE = "/projects/proj_one/threads/thr_one?message=12#event-12";
@@ -198,10 +200,48 @@ beforeEach(() => {
afterEach(() => {
cleanup();
+ setCompactSecondaryPanelPresentation("closed");
vi.restoreAllMocks();
window.localStorage.clear();
});
+describe("mobile workspace sidebar access", () => {
+ it.each([
+ "/plugins",
+ "/plugins/plugin-api-docs",
+ "/plugins/plugin-api-docs/plugin-api",
+ "/settings",
+ "/skills",
+ ])(
+ "opens and collapses the sidebar on %s with a full detail panel",
+ async (route) => {
+ setCompactSecondaryPanelPresentation("full");
+ render(
+
+
+
+
+ Workspace content
+
+
+
+ ,
+ );
+ const toggle = screen.getByRole("button", { name: /^Toggle sidebar/ });
+ expect(toggle.getAttribute("aria-expanded")).toBe("false");
+ fireEvent.click(toggle);
+ await waitFor(() =>
+ expect(toggle.getAttribute("aria-expanded")).toBe("true"),
+ );
+ fireEvent.click(toggle);
+ await waitFor(() =>
+ expect(toggle.getAttribute("aria-expanded")).toBe("false"),
+ );
+ expect(getRoot().hasAttribute("inert")).toBe(false);
+ },
+ );
+});
+
describe("AppLayout Back to app", () => {
it.each(SECONDARY_ROUTES)(
"returns from %s with the remembered query and hash, then releases Escape",
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index bf7b97a798..f8951e1f06 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -1,12 +1,5 @@
import { type MouseEvent as ReactMouseEvent, type ReactNode } from "react";
-import {
- useCallback,
- useEffect,
- useMemo,
- useRef,
- useState,
- useSyncExternalStore,
-} from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { atom, useAtom, useAtomValue, useStore } from "jotai";
import { atomWithStorage } from "jotai/utils";
@@ -48,10 +41,6 @@ import { useRouteState } from "@/hooks/useRouteState";
import { getThreadDisplayTitle } from "@/lib/thread-title";
import { cn } from "@bb/shared-ui/lib/utils";
import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers";
-import {
- getCompactSecondaryPanelPresentation,
- subscribeCompactSecondaryPanelShelfShowing,
-} from "@/components/ui/secondary-panel-shelf-visibility";
import { ProjectPathDialog } from "@/components/dialogs/ProjectPathDialog";
import { ProjectActionsMenu } from "@/components/project/ProjectActionsMenu";
import { ProjectActionsProvider } from "@/components/project/ProjectActionsProvider";
@@ -208,15 +197,7 @@ function SidebarTriggerOverlay({
usesDesktopChrome,
}: SidebarTriggerOverlayProps) {
const isCompactViewport = useIsCompactViewport();
- const compactSecondaryPanelPresentation = useSyncExternalStore(
- subscribeCompactSecondaryPanelShelfShowing,
- getCompactSecondaryPanelPresentation,
- () => "closed",
- );
const shortcut = useAppCommandShortcut("sidebar.toggle");
- if (isCompactViewport && compactSecondaryPanelPresentation !== "closed") {
- return null;
- }
const triggerProps = {
"aria-label": shortcut
? `Toggle sidebar (${shortcut.label})`
@@ -256,7 +237,11 @@ function SidebarTriggerOverlay({
return (
({
+ appToast: {
+ dismiss: vi.fn(),
+ error: vi.fn(),
+ loading: vi.fn(),
+ message: vi.fn(),
+ success: vi.fn(),
+ warning: vi.fn(),
+ },
+}));
+
+function disabledPluginMutationResponse(id: string) {
+ return {
+ ok: true,
+ plugin: makeInstalledPlugin({
+ id,
+ enabled: false,
+ status: "disabled",
+ app: { hasApp: true, bundle: null },
+ }),
+ };
+}
vi.mock("@/components/sidebar/useSidebarReorderDnd", async (importOriginal) => {
const actual =
@@ -116,6 +147,9 @@ interface RenderSidebarItemsOptions {
storedVisibleKeys?: string[] | null;
compactViewport?: boolean;
compactCustomizeMode?: boolean;
+ initialEntry?: string;
+ initialEntries?: string[];
+ initialLayout?: SplitLayout;
onCompactCustomizeModeChange?: (isCustomizing: boolean) => void;
splitEnabled?: boolean;
}
@@ -168,13 +202,18 @@ function renderSidebarItems(options: RenderSidebarItemsOptions = {}) {
focusedPaneId: "pane-1",
});
}
+ if (options.initialLayout) store.set(splitLayoutAtom, options.initialLayout);
const view = render(
-
+
@@ -188,7 +227,15 @@ function renderSidebarItems(options: RenderSidebarItemsOptions = {}) {
}
function LocationProbe() {
- return ;
+ const location = useLocation();
+ const navigate = useNavigate();
+ return (
+ <>
+
+
+
+ >
+ );
}
function panelRowNames(
@@ -262,7 +309,7 @@ async function openCustomizeFromContextMenu(
}
beforeEach(() => {
- vi.mocked(useSidebarReorderDnd).mockClear();
+ vi.clearAllMocks();
resetPluginFrontendBootStateForTest();
markPluginFrontendsSettled();
window.localStorage.clear();
@@ -277,6 +324,7 @@ afterEach(() => {
resetPluginSlotStoreForTest();
resetAllCrashedPluginSlotsForTest();
vi.restoreAllMocks();
+ vi.unstubAllGlobals();
window.localStorage.clear();
});
@@ -336,7 +384,7 @@ describe("PluginNavSidebarItems", () => {
expect(screen.queryByText("Plugins")).toBeNull();
});
- it("shows one plugin without a More row and reaches Customize from the row menu", async () => {
+ it("shows one plugin without a More row", () => {
registerPanel("docs", "Docs");
renderSidebarItems();
@@ -347,13 +395,9 @@ describe("PluginNavSidebarItems", () => {
screen.queryByRole("button", { name: "Customize sidebar navigation" }),
).toBeNull();
- await openCustomizeFromContextMenu(
- screen.getByRole("button", { name: "Docs" }),
- );
-
- expect(customizeRows().map((row) => row.textContent?.trim())).toEqual([
- "Docs",
- ]);
+ expect(
+ screen.queryByRole("button", { name: "Docs panel options" }),
+ ).not.toBeNull();
});
it("keeps an accessory-less plugin row unchanged", () => {
@@ -398,6 +442,330 @@ describe("PluginNavSidebarItems", () => {
).toBe("always");
});
+ it.each([false, true])(
+ "uses the focused plugin action set for the options button and right-click (compact=%s)",
+ async (compactViewport) => {
+ registerPanel("docs", "Docs");
+ renderSidebarItems({ splitEnabled: true, compactViewport });
+
+ const trigger = screen.getByRole("button", {
+ name: "Docs panel options",
+ });
+ if (compactViewport) {
+ fireEvent.click(trigger);
+ } else {
+ fireEvent.pointerDown(trigger, { button: 0 });
+ }
+ await screen.findByRole("menuitem", { name: "Hide from sidebar" });
+ const dropdownRole = compactViewport ? "dialog" : "menu";
+ const dropdownMenu = screen.getByRole(dropdownRole);
+ const expected = [
+ ...(compactViewport ? [] : [["Open in split", "Columns2"]]),
+ ["View details", "Info"],
+ ["Hide from sidebar", "EyeOff"],
+ ["Disable", "Unavailable"],
+ ] as const;
+ const expectFocusedMenu = (menu: HTMLElement) => {
+ expect(
+ within(menu)
+ .getAllByRole("menuitem")
+ .map((item) => item.textContent?.trim()),
+ ).toEqual(expected.map(([label]) => label));
+ expect(within(menu).getAllByRole("separator")).toHaveLength(1);
+ for (const [label, icon] of expected) {
+ expect(
+ within(menu)
+ .getByRole("menuitem", { name: label })
+ .querySelector(`[data-icon="${icon}"]`),
+ ).not.toBeNull();
+ }
+ };
+ expectFocusedMenu(dropdownMenu);
+ fireEvent.keyDown(dropdownMenu, { key: "Escape" });
+ await waitFor(() => expect(screen.queryByRole(dropdownRole)).toBeNull());
+
+ fireEvent.contextMenu(screen.getByRole("button", { name: "Docs" }));
+ expectFocusedMenu(await screen.findByRole("menu"));
+ },
+ );
+
+ it("hides an active plugin through the compact menu without disabling or navigating", async () => {
+ const fetchMock = vi.fn();
+ vi.stubGlobal("fetch", fetchMock);
+ registerPanel("docs", "Docs");
+ const initialLayout: SplitLayout = {
+ root: {
+ type: "pane",
+ paneId: "docs-pane",
+ content: {
+ kind: "plugin-panel",
+ pluginId: "docs",
+ panelPath: "main",
+ subPath: "",
+ },
+ },
+ focusedPaneId: "docs-pane",
+ };
+ const { store } = renderSidebarItems({
+ compactViewport: true,
+ initialEntry: "/plugins/docs/main",
+ initialLayout,
+ });
+ fireEvent.click(screen.getByRole("button", { name: "Docs panel options" }));
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "Hide from sidebar" }),
+ );
+
+ expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([]);
+ expect(visibleRowKeys()).toEqual([]);
+ expect(store.get(splitLayoutAtom)).toEqual(initialLayout);
+ expect(screen.getByTestId("location-path").textContent).toBe(
+ "/plugins/docs/main",
+ );
+ expect(fetchMock).not.toHaveBeenCalled();
+ fireEvent.click(moreTrigger());
+ expect(
+ await screen.findByRole("menuitem", { name: "Docs" }),
+ ).not.toBeNull();
+ expect(
+ screen.getAllByRole("menuitem").map((item) => item.textContent?.trim()),
+ ).toEqual(["Docs", "Customize sidebar"]);
+ });
+
+ it("opens plugin details and omits split when the layout cannot split", async () => {
+ registerPanel("docs", "Docs");
+ renderSidebarItems();
+
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Docs panel options" }),
+ { button: 0 },
+ );
+ expect(
+ screen.queryByRole("menuitem", { name: "Open in split" }),
+ ).toBeNull();
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "View details" }),
+ );
+ expect(screen.getByTestId("location-path").textContent).toBe(
+ "/plugins/docs",
+ );
+ });
+
+ it("opens details in the active workspace without changing its route", async () => {
+ const open = vi.fn(() => true);
+ function Workspace() {
+ usePublishPluginDetailOpener(open, true);
+ return null;
+ }
+ render();
+ registerPanel("docs", "Docs");
+ renderSidebarItems({ initialEntry: "/plugins/docs/main" });
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Docs panel options" }),
+ { button: 0 },
+ );
+ fireEvent.click(
+ await screen.findByRole("menuitem", { name: "View details" }),
+ );
+ expect(open).toHaveBeenCalledWith({ pluginId: "docs", title: "Docs" });
+ expect(screen.getByTestId("location-path").textContent).toBe(
+ "/plugins/docs/main",
+ );
+ });
+
+ it.each([
+ { pluginId: "docs", title: "Docs" },
+ {
+ pluginId: AUTOMATIONS_PLUGIN_ID,
+ title: "Automations",
+ },
+ ])(
+ "replaces $title with New thread before disabling",
+ async ({ pluginId, title }) => {
+ let completeDisable: (response: Response) => void = () => {};
+ const fetchMock = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ completeDisable = resolve;
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ registerPanel(pluginId, title);
+ const { store } = renderSidebarItems({
+ initialEntries: ["/skills", `/plugins/${pluginId}/main`],
+ initialLayout: {
+ root: {
+ type: "pane",
+ paneId: "docs",
+ content: {
+ kind: "plugin-panel",
+ pluginId,
+ panelPath: "main",
+ subPath: "",
+ },
+ },
+ focusedPaneId: "docs",
+ },
+ });
+
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: `${title} panel options` }),
+ { button: 0 },
+ );
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ expect(String(fetchMock.mock.calls[0]?.[0])).toContain(
+ `/plugins/${pluginId}/disable`,
+ );
+ await waitFor(() =>
+ expect(screen.getByTestId("location-path").textContent).toBe("/"),
+ );
+ expect(store.get(splitLayoutAtom)?.root).toMatchObject({
+ content: { kind: "new-thread" },
+ });
+ expect(appToast.success).not.toHaveBeenCalled();
+ await act(async () => {
+ completeDisable(
+ new Response(
+ JSON.stringify(disabledPluginMutationResponse(pluginId)),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ ),
+ );
+ });
+ expect(appToast.success).toHaveBeenCalledWith(`${title} disabled`);
+ fireEvent.click(screen.getByRole("button", { name: "History back" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("location-path").textContent).toBe("/skills"),
+ );
+ fireEvent.click(screen.getByRole("button", { name: "History forward" }));
+ await waitFor(() =>
+ expect(screen.getByTestId("location-path").textContent).toBe("/"),
+ );
+ },
+ );
+
+ it.each(["docs", "github"])(
+ "closes only the disabled plugin panes with %s focused",
+ async (focusedPaneId) => {
+ registerPanel("docs", "Docs");
+ registerPanel("github", "GitHub");
+ const { store } = renderSidebarItems({
+ initialEntry: `/plugins/${focusedPaneId}/main`,
+ initialLayout: {
+ root: {
+ type: "split",
+ dir: "row",
+ sizes: [1, 1, 1],
+ children: [
+ {
+ type: "pane",
+ paneId: "docs",
+ content: {
+ kind: "plugin-panel",
+ pluginId: "docs",
+ panelPath: "main",
+ subPath: "",
+ },
+ },
+ {
+ type: "pane",
+ paneId: "github",
+ content: {
+ kind: "plugin-panel",
+ pluginId: "github",
+ panelPath: "main",
+ subPath: "",
+ },
+ },
+ {
+ type: "pane",
+ paneId: "docs-other",
+ content: {
+ kind: "plugin-panel",
+ pluginId: "docs",
+ panelPath: "other",
+ subPath: "",
+ },
+ },
+ ],
+ },
+ focusedPaneId,
+ },
+ });
+ store.set(maximizedPaneIdAtom, "docs");
+ const layoutsAtDisable: Array = [];
+ const fetchMock = vi.fn(async () => {
+ layoutsAtDisable.push(store.get(splitLayoutAtom));
+ return new Response(
+ JSON.stringify(disabledPluginMutationResponse("docs")),
+ {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ },
+ );
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Docs panel options" }),
+ { button: 0 },
+ );
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
+ await waitFor(() =>
+ expect(appToast.success).toHaveBeenCalledWith("Docs disabled"),
+ );
+ const survivingLayout = {
+ root: {
+ type: "pane",
+ paneId: "github",
+ content: {
+ kind: "plugin-panel",
+ pluginId: "github",
+ panelPath: "main",
+ subPath: "",
+ },
+ },
+ focusedPaneId: "github",
+ };
+ expect(layoutsAtDisable).toEqual([survivingLayout]);
+ expect(store.get(splitLayoutAtom)).toEqual(survivingLayout);
+ expect(store.get(maximizedPaneIdAtom)).toBeNull();
+ expect(screen.getByTestId("location-path").textContent).toBe(
+ "/plugins/github/main",
+ );
+ },
+ );
+
+ it("keeps the current workspace when disabling a plugin that is not open", async () => {
+ registerPanel("docs", "Docs");
+ const fetchMock = vi.fn(
+ async () =>
+ new Response(JSON.stringify(disabledPluginMutationResponse("docs")), {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+ vi.stubGlobal("fetch", fetchMock);
+ const { store } = renderSidebarItems({
+ initialEntry: "/",
+ splitEnabled: true,
+ });
+ const originalLayout = store.get(splitLayoutAtom);
+ fireEvent.pointerDown(
+ screen.getByRole("button", { name: "Docs panel options" }),
+ { button: 0 },
+ );
+ fireEvent.click(await screen.findByRole("menuitem", { name: "Disable" }));
+ await waitFor(() =>
+ expect(appToast.success).toHaveBeenCalledWith("Docs disabled"),
+ );
+ expect(store.get(splitLayoutAtom)).toBe(originalLayout);
+ expect(screen.getByTestId("location-path").textContent).toBe("/");
+ });
+
it("bounds and truncates a long sidebar accessory", () => {
registerPanel("tasks", "Tasks", () => (
123456789012345678901234567890
@@ -782,10 +1150,12 @@ describe("PluginNavSidebarItems", () => {
it("keeps launch and visibility as distinct targets with a clear row hover state", async () => {
const labels = ["One", "Two", "Three", "Four"];
labels.forEach((label, index) => registerPanel(`plugin-${index}`, label));
- const { store, unmount } = renderSidebarItems();
+ const { store, unmount } = renderSidebarItems({
+ builtInEntries: [builtInEntry("new-thread", "New thread")],
+ });
await openCustomizeFromContextMenu(
- screen.getByRole("button", { name: "One" }),
+ screen.getByRole("button", { name: "New thread" }),
);
const choices = screen.getAllByRole("checkbox");
await waitFor(() =>
@@ -793,26 +1163,28 @@ describe("PluginNavSidebarItems", () => {
document.activeElement?.getAttribute(
"data-sidebar-navigation-customize-launch",
),
- ).toBe("plugin-0/main"),
+ ).toBe("__bb__/new-thread"),
);
expect(choices.map((choice) => choice.getAttribute("data-state"))).toEqual([
"checked",
"checked",
"checked",
"checked",
+ "checked",
]);
expect(
document.querySelectorAll("[data-plugin-nav-customize-drag-handle]"),
- ).toHaveLength(4);
+ ).toHaveLength(5);
expect(
customizeRows()[0]?.classList.contains("hover:bg-sidebar-accent"),
).toBe(true);
- fireEvent.click(choices[0]!);
+ fireEvent.click(choices[1]!);
expect(
screen.getByRole("list", { name: "Sidebar navigation" }),
).not.toBeNull();
expect(store.get(pluginNavVisiblePanelKeysAtom)).toEqual([
+ "__bb__/new-thread",
"plugin-1/main",
"plugin-2/main",
"plugin-3/main",
@@ -880,7 +1252,7 @@ describe("PluginNavSidebarItems", () => {
]);
if (mode !== "sidebar") {
await openCustomizeFromContextMenu(
- screen.getByRole("button", { name: "Docs" }),
+ screen.getByRole("button", { name: "New thread" }),
);
}
reorderSidebar("tasks/main", "docs/main");
@@ -1126,17 +1498,21 @@ describe("PluginNavSidebarItems", () => {
it("preserves modifier-click when launching a plugin from Customize", async () => {
registerPanel("docs", "Docs");
- const { store } = renderSidebarItems({ splitEnabled: true });
+ const { store } = renderSidebarItems({
+ builtInEntries: [builtInEntry("new-thread", "New thread")],
+ splitEnabled: true,
+ });
await openCustomizeFromContextMenu(
- screen.getByRole("button", { name: "Docs" }),
+ screen.getByRole("button", { name: "New thread" }),
);
- const row = customizeRows()[0];
- expect(row).toBeDefined();
- fireEvent.click(
- within(row as HTMLElement).getByRole("button", { name: "Docs" }),
- { metaKey: true },
+ const row = customizeRows().find((item) =>
+ item.textContent?.includes("Docs"),
);
+ if (!row) throw new Error("Docs customization row is missing");
+ fireEvent.click(within(row).getByRole("button", { name: "Docs" }), {
+ metaKey: true,
+ });
const layout = store.get(splitLayoutAtom);
expect(layout).not.toBeNull();
diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
index b0d10b3dd4..16db155ed6 100644
--- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
+++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx
@@ -11,9 +11,13 @@ import {
type ReactNode,
} from "react";
import { useLocation, useNavigate } from "react-router-dom";
-import { useAtom } from "jotai";
+import { useAtom, useStore } from "jotai";
+import { flushSync } from "react-dom";
import { DndContext, type DragEndEvent } from "@dnd-kit/core";
-import { FilterHorizontalIcon } from "@hugeicons/core-free-icons";
+import {
+ FilterHorizontalIcon,
+ UnavailableIcon,
+} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import {
SortableContext,
@@ -48,7 +52,10 @@ import { PluginSlotMount } from "@/components/plugin/PluginSlotMount";
import { PROJECT_LIST_ACTION_BUTTON_CLASS } from "@/components/sidebar/ProjectList";
import {
AUTOMATIONS_PLUGIN_ID,
+ getPluginDetailRoutePath,
getPluginPanelRoutePath,
+ getPluginPanelRoutePluginId,
+ getRootComposeRoutePath,
} from "@/lib/route-paths";
import {
usePluginNavPanelChrome,
@@ -77,6 +84,18 @@ import {
import { useSidebarSortable } from "@/components/sidebar/sortableMotion";
import { useSidebarReorderDnd } from "@/components/sidebar/useSidebarReorderDnd";
import type { SidebarSortableDragBindings } from "@/components/sidebar/sortableMotion";
+import { appToast } from "@/components/ui/app-toast";
+import { invalidatePluginList } from "@/hooks/cache-owners/plugin-cache-owner";
+import { setPluginEnabled } from "@/hooks/queries/plugin-settings-queries";
+import { appQueryClient } from "@/lib/app-query-client";
+import { maximizedPaneIdAtom, splitLayoutAtom } from "@/lib/split-layout/atoms";
+import {
+ findPane,
+ listPanes,
+ removePane,
+ replacePaneContent,
+} from "@/lib/split-layout";
+import { focusedPaneRoute } from "@/views/thread-detail/splitThreadNavigation";
import {
pluginNavPanelOrderAtom,
pluginNavVisiblePanelKeysAtom,
@@ -89,6 +108,7 @@ import {
togglePluginNavPanelVisibility,
} from "./pluginNavSidebarOrder";
import { haveSameOrder, reorderStoredOrder } from "@/lib/stored-order";
+import { openPluginDetailsInWorkspace } from "./plugin-detail-opener";
const MORE_TRIGGER_TEST_ID = "sidebar-navigation-more-trigger";
@@ -203,6 +223,7 @@ function PluginNavSidebarItemList({
}) {
const location = useLocation();
const navigate = useNavigate();
+ const store = useStore();
const isCompactViewport = useIsCompactViewport();
const splitActions = usePaneContentSplitActions();
const [storedOrder, setStoredOrder] = useAtom(pluginNavPanelOrderAtom);
@@ -232,6 +253,61 @@ function PluginNavSidebarItemList({
() => seedSkillsNavigationPreference(storedOrder, storedVisibleKeys),
[storedOrder, storedVisibleKeys],
);
+ const [disablePending, setDisablePending] = useState(false);
+ const handleDisable = useCallback(
+ async (row: PluginSidebarNavRow) => {
+ const pluginId = row.chrome.pluginId;
+ setDisablePending(true);
+ try {
+ const current = store.get(splitLayoutAtom);
+ let next = current;
+ if (next !== null) {
+ for (const pane of listPanes(next.root)) {
+ if (
+ pane.content.kind !== "plugin-panel" ||
+ pane.content.pluginId !== pluginId
+ )
+ continue;
+ next =
+ listPanes(next.root).length === 1
+ ? replacePaneContent(next, pane.paneId, { kind: "new-thread" })
+ : removePane(next, pane.paneId);
+ }
+ }
+ flushSync(() => {
+ if (next !== current) {
+ store.set(splitLayoutAtom, next);
+ const maximized = store.get(maximizedPaneIdAtom);
+ if (
+ maximized !== null &&
+ (next === null ||
+ listPanes(next.root).length < 2 ||
+ findPane(next.root, maximized) === null)
+ ) {
+ store.set(maximizedPaneIdAtom, null);
+ }
+ }
+ if (getPluginPanelRoutePluginId(location.pathname) === pluginId) {
+ onNavigate?.();
+ void navigate(
+ (next && focusedPaneRoute(next)) ?? getRootComposeRoutePath(),
+ { replace: true },
+ );
+ }
+ });
+ await setPluginEnabled(fetch, pluginId, false);
+ appToast.success(`${row.title} disabled`);
+ } catch (error) {
+ appToast.error(`Failed to disable ${row.title}`, {
+ description: error instanceof Error ? error.message : String(error),
+ });
+ } finally {
+ await invalidatePluginList({ queryClient: appQueryClient });
+ setDisablePending(false);
+ }
+ },
+ [location.pathname, navigate, onNavigate, store],
+ );
const newLeadingKeys = useMemo(
() =>
leadingOrderKeys.filter((key) => !seededPreferences.order.includes(key)),
@@ -366,7 +442,8 @@ function PluginNavSidebarItemList({
pathname: location.pathname,
splitEnabled,
onHide: (key: string) => setPanelVisible(key, false),
- onCustomize: openCustomize,
+ disablePending,
+ onDisable: (row: PluginSidebarNavRow) => void handleDisable(row),
};
const handleActivate = useCallback(
@@ -929,8 +1006,9 @@ interface SidebarNavRowItemProps {
pathname: string;
onNavigate?: () => void;
splitEnabled: boolean;
- onHide?: (key: string) => void;
- onCustomize?: () => void;
+ disablePending: boolean;
+ onHide: (key: string) => void;
+ onDisable: (row: PluginSidebarNavRow) => void;
dragBindings?: SidebarSortableDragBindings;
rowRef?: (element: HTMLElement | null) => void;
rowStyle?: CSSProperties;
@@ -948,24 +1026,51 @@ function SidebarNavRowItem({
type PluginNavRowMenuSurface = "context" | "dropdown";
-function PluginNavRowVisibilityMenuItem({
- onSelect,
+function PluginNavRowMenuItems({
+ disablePending,
+ onDisable,
+ onHide,
+ onOpenInSplit,
+ onOpenDetails,
surface,
}: {
- onSelect: () => void;
+ disablePending: boolean;
+ onDisable: () => void;
+ onHide: () => void;
+ onOpenInSplit?: () => void;
+ onOpenDetails: () => void;
surface: PluginNavRowMenuSurface;
}) {
- const content = (
+ const Item = surface === "context" ? ContextMenuItem : DropdownMenuItem;
+ const Separator =
+ surface === "context" ? ContextMenuSeparator : DropdownMenuSeparator;
+ return (
<>
-
- Hide from sidebar
+ {onOpenInSplit !== undefined ? (
+ -
+
+ Open in split
+
+ ) : null}
+ -
+
+ View details
+
+ -
+
+ Hide from sidebar
+
+
+ -
+
+ Disable
+
>
);
- return surface === "context" ? (
- {content}
- ) : (
- {content}
- );
}
export function ResourceNavSidebarItem({
@@ -1001,6 +1106,7 @@ function PluginNavSidebarItem({
row,
pathname,
onNavigate,
+ onDisable,
splitEnabled,
...props
}: SidebarNavRowItemProps) {
@@ -1049,6 +1155,21 @@ function PluginNavSidebarItem({
splitMiniMap={splitIndicator.miniMap}
accessory={sidebarAccessory}
onPointerDown={onPointerDown}
+ onOpenInSplit={
+ splitEnabled && !isCompactViewport ? openInSplit : undefined
+ }
+ onOpenDetails={() => {
+ onNavigate?.();
+ if (
+ openPluginDetailsInWorkspace({
+ pluginId: chrome.pluginId,
+ title: chrome.title,
+ })
+ )
+ return;
+ void navigate(getPluginDetailRoutePath({ pluginId: chrome.pluginId }));
+ }}
+ onDisable={() => onDisable(row)}
onSelect={(event) => {
onNavigate?.();
if (event.metaKey || event.ctrlKey) {
@@ -1069,8 +1190,11 @@ interface SidebarNavRowChromeProps {
isActive: boolean;
onSelect: (event: ReactMouseEvent) => void;
onPointerDown?: PointerEventHandler;
- onHide?: (key: string) => void;
- onCustomize?: () => void;
+ onOpenInSplit?: () => void;
+ onOpenDetails: () => void;
+ onDisable: () => void;
+ onHide: (key: string) => void;
+ disablePending: boolean;
splitMiniMap?: MiniMapSlot[] | null;
accessory?: ReactNode;
dragBindings?: SidebarSortableDragBindings;
@@ -1086,8 +1210,11 @@ function SidebarNavRowChrome({
isActive,
onSelect,
onPointerDown,
+ onOpenInSplit,
+ onOpenDetails,
+ onDisable,
onHide,
- onCustomize,
+ disablePending,
splitMiniMap = null,
accessory,
dragBindings,
@@ -1098,27 +1225,14 @@ function SidebarNavRowChrome({
const { onKeyDown: _keyboardDragActivator, ...pointerDragListeners } =
dragBindings?.listeners ?? {};
const menuItems = (surface: PluginNavRowMenuSurface): ReactNode => (
- <>
- onHide?.(rowKey)}
- />
- {onCustomize === undefined ? null : surface === "context" ? (
- <>
-
-
-
-
- >
- ) : (
- <>
-
-
-
-
- >
- )}
- >
+ onHide(rowKey)}
+ onOpenInSplit={onOpenInSplit}
+ onOpenDetails={onOpenDetails}
+ />
);
return (
diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
index f13a915b4b..218927b9aa 100644
--- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
+++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx
@@ -1,6 +1,6 @@
// @vitest-environment jsdom
-import { useEffect, type ReactNode } from "react";
+import { useContext, useEffect, type ReactNode } from "react";
import { Provider } from "jotai";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
@@ -43,6 +43,16 @@ import { buildThreadHandoffLocationState } from "@bb/client-core";
import { makeThreadListEntry } from "@bb/test-helpers/domain-fixtures";
import { makeProjectWithThreadsResponse } from "@/test/fixtures/projects";
import { RootComposeView } from "@/views/RootComposeView";
+import { ROOT_COMPOSE_FIXED_PANEL_STATE_ID } from "@/views/RootComposePanelTabContent";
+import { resetFixedPanelTabsStateForTest } from "@/lib/fixed-panel-tabs";
+import {
+ createEmptyFixedPanelTabsState,
+ createTerminalFixedPanelTab,
+ getFixedPanelTabsStateStorageKey,
+ serializeFixedPanelTabsState,
+} from "@/lib/fixed-panel-tabs-state";
+import { PluginDetailPanelContext } from "./plugin-detail-navigation";
+import { openPluginDetailsInWorkspace } from "./plugin-detail-opener";
import { PluginNewThreadComposer } from "./PluginNewThreadComposer";
const mocks = vi.hoisted(() => ({
@@ -55,8 +65,45 @@ const mocks = vi.hoisted(() => ({
extraProjects: [] as Array>,
promptHistoryQueryOptions: [] as Array<{ enabled?: boolean } | undefined>,
environmentProviders: [] as unknown[],
+ closeTerminal: vi.fn(),
+}));
+
+vi.mock("@/views/RootComposePanelCommandHandlers", () => ({
+ RootComposePanelCommandHandlers: ({
+ onClose,
+ }: {
+ onClose: () => boolean;
+ }) => {
+ const details = useContext(PluginDetailPanelContext);
+ return (
+
+ );
+ },
+}));
+
+vi.mock("@/components/secondary-panel/SecondaryPanelLayout", () => ({
+ SecondaryPanelLayout: ({ main }: { main: ReactNode }) => main,
}));
+vi.mock("@/hooks/queries/thread-terminal-queries", async (importOriginal) => {
+ const actual =
+ await importOriginal<
+ typeof import("@/hooks/queries/thread-terminal-queries")
+ >();
+ return {
+ ...actual,
+ useTerminals: () => ({ data: undefined }),
+ useEnvironmentTerminals: () => ({ data: undefined }),
+ useCloseTerminal: () => ({ mutate: mocks.closeTerminal }),
+ useCloseEnvironmentTerminal: () => ({ mutate: mocks.closeTerminal }),
+ };
+});
+
vi.mock("@/components/promptbox/NewThreadPromptBox", () => ({
NewThreadPromptBox: (props: Record) => {
mocks.promptBoxProps.push(props);
@@ -555,6 +602,8 @@ async function submit(): Promise {
describe("PluginNewThreadComposer seeding", () => {
beforeEach(() => {
+ resetFixedPanelTabsStateForTest();
+ mocks.closeTerminal.mockClear();
mocks.promptBoxProps.length = 0;
mocks.promptHistoryQueryOptions.length = 0;
mocks.copyAttachments.mockReset();
@@ -1118,6 +1167,56 @@ describe("PluginNewThreadComposer seeding", () => {
).toBe(true);
});
+ it("closes visible plugin details before an underlying terminal", async () => {
+ const terminal = createTerminalFixedPanelTab({
+ terminalId: "terminal-under-details",
+ });
+ const state = createEmptyFixedPanelTabsState({ lastUsedAt: Date.now() });
+ window.localStorage.setItem(
+ getFixedPanelTabsStateStorageKey({
+ threadId: ROOT_COMPOSE_FIXED_PANEL_STATE_ID,
+ }),
+ serializeFixedPanelTabsState({
+ state: {
+ ...state,
+ secondary: {
+ ...state.secondary,
+ tabs: [terminal],
+ activeTabId: terminal.id,
+ isOpen: true,
+ },
+ },
+ }),
+ );
+ window.localStorage.setItem("bb.root-compose.project-id", "proj_1");
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ const router = createMemoryRouter([
+ { path: "/", element: },
+ ]);
+ render(
+
+
+
+
+ ,
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ const command = screen.getByRole("button", { name: "Close panel command" });
+ expect(command.dataset.activeDetail).toBe("docs");
+ fireEvent.click(command);
+ expect(mocks.closeTerminal).not.toHaveBeenCalled();
+ expect(command.dataset.activeDetail).toBe("");
+ fireEvent.click(command);
+ expect(mocks.closeTerminal).toHaveBeenCalledWith(
+ { mode: "force", terminalId: "terminal-under-details" },
+ expect.anything(),
+ );
+ });
+
it("keeps a seeded fork's exact reuse selection while the sidebar bootstrap settles", async () => {
mocks.sidebarNavigationSettled = false;
const submitted: NewThreadRequest[] = [];
diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
index 1dac1923a0..9f8332795a 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.test.tsx
@@ -1,6 +1,7 @@
// @vitest-environment jsdom
import {
+ act,
cleanup,
fireEvent,
render,
@@ -22,6 +23,7 @@ import {
serializeFixedPanelTabsState,
} from "@/lib/fixed-panel-tabs-state";
import { PluginPanelRightPanelHost } from "./PluginPanelRightPanelHost";
+import { openPluginDetailsInWorkspace } from "./plugin-detail-opener";
import { getPluginPagePanelStateId } from "./plugin-page-panel-state";
import { useAppNavigationHost } from "@/lib/app-navigation-host";
import {
@@ -805,6 +807,21 @@ describe("PluginPanelRightPanelHost", () => {
);
});
+ it("accepts sidebar detail requests before the plugin panel registers", async () => {
+ fixedTabState.panelRegistered = false;
+ renderHost("board", "", createStore(), true);
+ act(() => {
+ expect(
+ openPluginDetailsInWorkspace({ pluginId: "secrets", title: "Secrets" }),
+ ).toBe(true);
+ });
+ expect(await screen.findByText("Details for secrets")).toBeTruthy();
+ expect(screen.getByTestId("current-path").textContent).toBe(
+ "/plugins/demo/board",
+ );
+ expect(screen.getByText("Plugin page")).toBeTruthy();
+ });
+
it("observes only the selected detail tab while retaining inactive tab metadata", async () => {
renderHost("board", "", createStore(), true);
diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
index 40c641d15b..38f248e914 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
@@ -1,6 +1,4 @@
import {
- lazy,
- Suspense,
useCallback,
useEffect,
useLayoutEffect,
@@ -32,7 +30,6 @@ import {
LazyBrowserTabDeck,
LazyHostScopedFilePreviewTabContent,
LazyNewTabPage,
- SecondaryPanelContentSkeleton,
LazyThreadSecondaryPanel,
LazyThreadStorageFilePreviewTabContent,
LazyThreadTerminalPanel,
@@ -98,6 +95,7 @@ import { PluginPanelTabContent } from "./PluginPanelActions";
import { PluginDetailRouteNavigationProvider } from "@/components/ui/app-route-anchor";
import { usePluginCatalogSearch } from "@/hooks/queries/plugin-catalog-queries";
import { usePluginList } from "@/hooks/queries/plugin-settings-queries";
+import { PluginDetailTabContent } from "./plugin-detail-navigation";
const TERMINAL_COLS = 100;
const TERMINAL_ROWS = 30;
@@ -121,12 +119,6 @@ const fixedTabTargetAtomFamily = atomFamily((_targetId: string) =>
atom(null),
);
-const LazyPluginDetailPaneView = lazy(() =>
- import("@/views/ToolsView").then(({ PluginDetailPaneView }) => ({
- default: PluginDetailPaneView,
- })),
-);
-
function marketplacePluginDetailTab(pluginId: string) {
return {
id: `${MARKETPLACE_PLUGIN_DETAIL_TAB_PREFIX}${pluginId}`,
@@ -134,14 +126,6 @@ function marketplacePluginDetailTab(pluginId: string) {
};
}
-function PluginDetailPanelContent({ pluginId }: { pluginId: string }) {
- return (
- }>
-
-
- );
-}
-
function PluginFixedTabContent({
fixedTabOwnerId,
isOpen,
@@ -443,7 +427,7 @@ export function PluginPanelRightPanelHost({
}, []);
const openPluginDetail = useCallback(
(nextPluginId: string) => {
- if (!pluginDetailTabsEnabled || panel === null) return false;
+ if (!pluginDetailTabsEnabled) return false;
setOpenedPluginIds((current) =>
current.includes(nextPluginId) ? current : [...current, nextPluginId],
);
@@ -452,7 +436,7 @@ export function PluginPanelRightPanelHost({
revealPanel();
return true;
},
- [panel, pluginDetailTabsEnabled, revealPanel],
+ [pluginDetailTabsEnabled, revealPanel],
);
const targetStore = useStore();
const fixedTabOwnerId = getPluginFixedTabOwnerId(
@@ -785,7 +769,7 @@ export function PluginPanelRightPanelHost({
revealPanel();
},
renderContent: () => (
-
+
),
statusLabel: null,
tab: marketplacePluginDetailTab(tabPluginId),
diff --git a/apps/app/src/components/plugin/plugin-detail-navigation.test.tsx b/apps/app/src/components/plugin/plugin-detail-navigation.test.tsx
new file mode 100644
index 0000000000..a9149db408
--- /dev/null
+++ b/apps/app/src/components/plugin/plugin-detail-navigation.test.tsx
@@ -0,0 +1,282 @@
+// @vitest-environment jsdom
+import { act, cleanup, render, screen } from "@testing-library/react";
+import { useState } from "react";
+import { arrayMove } from "@bb/client-core";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { ThreadSecondaryPanelProps } from "@/components/secondary-panel/ThreadSecondaryPanel";
+import {
+ PluginDetailPanelContext,
+ usePluginDetailPanelProps,
+ usePluginDetailPanelState,
+} from "./plugin-detail-navigation";
+import { openPluginDetailsInWorkspace } from "./plugin-detail-opener";
+
+const selectExisting = vi.fn();
+const closePanel = vi.fn();
+const existingTab = { id: "new-tab:existing", kind: "new-tab" as const };
+const baseProps: ThreadSecondaryPanelProps = {
+ activeTab: existingTab,
+ canUseGitUi: false,
+ metadataContent: null,
+ tabs: [
+ {
+ tab: existingTab,
+ label: "Existing tab",
+ leadingVisual: null,
+ statusLabel: null,
+ renderContent: () => null,
+ onClose: vi.fn(),
+ onSelect: selectExisting,
+ },
+ ],
+ fixedTabs: [],
+ isOpen: false,
+ onTabReorder: vi.fn(),
+ onPanelFocus: vi.fn(),
+ onClose: closePanel,
+ onCollapse: closePanel,
+ onOpenNewTab: vi.fn(),
+ isConversationCollapsed: false,
+ onToggleConversationCollapse: vi.fn(),
+ renderAsDrawer: false,
+};
+
+function PanelProbe({
+ id,
+ input = baseProps,
+}: {
+ id: string;
+ input?: ThreadSecondaryPanelProps;
+}) {
+ const props = usePluginDetailPanelProps(input);
+ return (
+
+ {props.tabs.map((tab, index) => (
+
+
+
+
+
+
+ ))}
+
+
+ );
+}
+
+function ReorderingWorkspace() {
+ const state = usePluginDetailPanelState("reordering", true);
+ const [tabs, setTabs] = useState([
+ baseProps.tabs[0],
+ {
+ ...baseProps.tabs[0],
+ label: "Another tab",
+ tab: { id: "new-tab:another", kind: "new-tab" as const },
+ },
+ ]);
+ return (
+
+ {
+ const from = tabs.findIndex((tab) => tab.tab.id === activeTabId);
+ const to = tabs.findIndex((tab) => tab.tab.id === overTabId);
+ if (from !== -1 && to !== -1) setTabs(arrayMove(tabs, from, to));
+ },
+ }}
+ />
+
+ );
+}
+
+function Workspace({
+ id,
+ focused,
+ revision = id,
+}: {
+ id: string;
+ focused: boolean;
+ revision?: string;
+}) {
+ const state = usePluginDetailPanelState(revision, focused);
+ return (
+
+
+
+ );
+}
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+describe("plugin details in the active workspace", () => {
+ it("preserves ordinary tab order after dragging across details and closing them", () => {
+ render();
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ act(() => screen.getByRole("button", { name: "Move Docs first" }).click());
+ act(() =>
+ screen.getByRole("button", { name: "Move Another tab first" }).click(),
+ );
+ expect(
+ screen
+ .getAllByRole("button", { name: /^Move .* left$/ })
+ .map((button) => button.textContent),
+ ).toEqual([
+ "Move Another tab left",
+ "Move Docs left",
+ "Move Existing tab left",
+ ]);
+ act(() => screen.getByRole("button", { name: "Close Docs" }).click());
+ expect(
+ screen
+ .getAllByRole("button", { name: /^Move .* left$/ })
+ .map((button) => button.textContent),
+ ).toEqual(["Move Another tab left", "Move Existing tab left"]);
+ });
+
+ it("retains detail-tab drag order among details and across existing tabs", () => {
+ const view = render();
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "tasks", title: "Tasks" }),
+ );
+ const order = () =>
+ screen
+ .getAllByRole("button", { name: /^Move .* left$/ })
+ .map((button) => button.textContent);
+ act(() => screen.getByRole("button", { name: "Move Tasks left" }).click());
+ expect(order()).toEqual([
+ "Move Existing tab left",
+ "Move Tasks left",
+ "Move Docs left",
+ ]);
+ act(() => screen.getByRole("button", { name: "Move Tasks left" }).click());
+ expect(order()).toEqual([
+ "Move Tasks left",
+ "Move Existing tab left",
+ "Move Docs left",
+ ]);
+ act(() => screen.getByRole("button", { name: "Docs" }).click());
+ expect(order()).toEqual([
+ "Move Tasks left",
+ "Move Existing tab left",
+ "Move Docs left",
+ ]);
+ act(() => screen.getByRole("button", { name: "Close Tasks" }).click());
+ expect(order()).toEqual(["Move Existing tab left", "Move Docs left"]);
+ view.rerender(
+ ,
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "tasks", title: "Tasks" }),
+ );
+ expect(order()).toEqual(["Move Existing tab left", "Move Tasks left"]);
+ });
+
+ it("opens and focuses a single detail tab without replacing existing tabs", () => {
+ render();
+ act(() => {
+ expect(
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ ).toBe(true);
+ });
+ expect(screen.getByTestId("workspace").dataset.active).toBe(
+ "marketplace-plugin:docs",
+ );
+ expect(screen.getByTestId("workspace").dataset.open).toBe("true");
+ act(() => screen.getByRole("button", { name: "Existing tab" }).click());
+ expect(selectExisting).toHaveBeenCalledOnce();
+ expect(screen.getByTestId("workspace").dataset.active).toBe(existingTab.id);
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ expect(screen.getAllByRole("button", { name: "Docs" })).toHaveLength(1);
+ act(() => screen.getByRole("button", { name: "Close Docs" }).click());
+ expect(screen.getByTestId("workspace").dataset.active).toBe(existingTab.id);
+ expect(screen.getByTestId("workspace").dataset.open).toBe("false");
+ });
+
+ it("targets only the focused pane and unregisters after it unmounts", () => {
+ const view = render(
+ <>
+
+
+ >,
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ expect(screen.getByTestId("left").dataset.active).toBe(
+ "marketplace-plugin:docs",
+ );
+ expect(screen.getByTestId("right").dataset.active).toBe(existingTab.id);
+ view.rerender(
+ <>
+
+
+ >,
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "tasks", title: "Tasks" }),
+ );
+ expect(screen.getByTestId("right").dataset.active).toBe(
+ "marketplace-plugin:tasks",
+ );
+ view.unmount();
+ expect(
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ ).toBe(false);
+ });
+
+ it("closes to the adjacent detail tab, then clears when the workspace changes", () => {
+ const view = render();
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "docs", title: "Docs" }),
+ );
+ act(() =>
+ openPluginDetailsInWorkspace({ pluginId: "tasks", title: "Tasks" }),
+ );
+ act(() => screen.getByRole("button", { name: "Close Tasks" }).click());
+ expect(screen.getByTestId("workspace").dataset.active).toBe(
+ "marketplace-plugin:docs",
+ );
+ act(() => screen.getByRole("button", { name: "Hide panel" }).click());
+ expect(closePanel).toHaveBeenCalledOnce();
+ expect(screen.getByTestId("workspace").dataset.open).toBe("false");
+ view.rerender(
+ ,
+ );
+ expect(screen.queryByRole("button", { name: "Docs" })).toBeNull();
+ });
+});
diff --git a/apps/app/src/components/plugin/plugin-detail-navigation.tsx b/apps/app/src/components/plugin/plugin-detail-navigation.tsx
new file mode 100644
index 0000000000..a4e507d2ea
--- /dev/null
+++ b/apps/app/src/components/plugin/plugin-detail-navigation.tsx
@@ -0,0 +1,205 @@
+import {
+ createContext,
+ lazy,
+ Suspense,
+ useCallback,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ useState,
+ type Key,
+} from "react";
+import { PluginIcon } from "./PluginIcon";
+import { arrayMove } from "@bb/client-core";
+import { arrangeByStoredOrder } from "@/lib/stored-order";
+import type { SecondaryPanelRenderableTab } from "@/components/secondary-panel/ThreadSecondaryPanel";
+import type { ThreadSecondaryPanelProps } from "@/components/secondary-panel/ThreadSecondaryPanel";
+import { SecondaryPanelContentSkeleton } from "@/components/secondary-panel/lazySecondaryPanelComponents";
+import {
+ usePublishPluginDetailOpener,
+ type PluginDetailDestination,
+ type PluginDetailOpener,
+} from "./plugin-detail-opener";
+
+const LazyPluginDetailPaneView = lazy(() =>
+ import("@/views/ToolsView").then(({ PluginDetailPaneView }) => ({
+ default: PluginDetailPaneView,
+ })),
+);
+
+export function PluginDetailTabContent({ pluginId }: { pluginId: string }) {
+ return (
+ }>
+
+
+ );
+}
+
+interface PluginDetailPanelState {
+ activePluginId: string | null;
+ destinations: readonly PluginDetailDestination[];
+ dismiss: () => void;
+ close: (pluginId: string) => void;
+ open: PluginDetailOpener;
+ tabOrder: readonly string[];
+ setTabOrder: (order: string[]) => void;
+}
+
+export const PluginDetailPanelContext =
+ createContext(null);
+
+export function usePluginDetailPanelState(resetKey: Key, isFocused: boolean) {
+ const [destinations, setDestinations] = useState(
+ [],
+ );
+ const [activePluginId, setActivePluginId] = useState(null);
+ const [tabOrder, setTabOrder] = useState([]);
+ const orderedDestinations = useMemo(
+ () =>
+ arrangeByStoredOrder({
+ items: destinations,
+ getId: (destination) => `marketplace-plugin:${destination.pluginId}`,
+ storedOrder: tabOrder,
+ }).ordered,
+ [destinations, tabOrder],
+ );
+ useLayoutEffect(() => {
+ // oxlint-disable-next-line react/set-state-in-effect
+ setDestinations([]);
+ // oxlint-disable-next-line react/set-state-in-effect
+ setActivePluginId(null);
+ // oxlint-disable-next-line react/set-state-in-effect
+ setTabOrder([]);
+ }, [resetKey]);
+ const dismiss = useCallback(() => setActivePluginId(null), []);
+ const open = useCallback((destination) => {
+ setDestinations((current) =>
+ current.some((entry) => entry.pluginId === destination.pluginId)
+ ? current
+ : [...current, destination],
+ );
+ setActivePluginId(destination.pluginId);
+ return true;
+ }, []);
+ const close = useCallback(
+ (pluginId: string) => {
+ const index = orderedDestinations.findIndex(
+ (entry) => entry.pluginId === pluginId,
+ );
+ const remaining = orderedDestinations.filter(
+ (entry) => entry.pluginId !== pluginId,
+ );
+ setDestinations(remaining);
+ setTabOrder((current) =>
+ current.filter((id) => id !== `marketplace-plugin:${pluginId}`),
+ );
+ if (activePluginId === pluginId) {
+ setActivePluginId(
+ remaining[Math.min(index, remaining.length - 1)]?.pluginId ?? null,
+ );
+ }
+ },
+ [activePluginId, orderedDestinations],
+ );
+ usePublishPluginDetailOpener(open, isFocused);
+ return useMemo(
+ () => ({
+ activePluginId,
+ destinations: orderedDestinations,
+ dismiss,
+ close,
+ open,
+ tabOrder,
+ setTabOrder,
+ }),
+ [activePluginId, orderedDestinations, dismiss, close, open, tabOrder],
+ );
+}
+
+export function usePluginDetailPanelProps(
+ props: ThreadSecondaryPanelProps,
+): ThreadSecondaryPanelProps {
+ const details = useContext(PluginDetailPanelContext);
+ const activeTabId = props.activeTab?.id;
+ const previousActiveTabId = useRef(activeTabId);
+ const dismiss = details?.dismiss;
+ useLayoutEffect(() => {
+ if (previousActiveTabId.current !== activeTabId) dismiss?.();
+ previousActiveTabId.current = activeTabId;
+ }, [activeTabId, dismiss]);
+ if (details === null || details.destinations.length === 0) return props;
+ const active = details.activePluginId;
+ const selectExisting = (select: () => void) => () => {
+ details.dismiss();
+ select();
+ };
+ const { ordered: tabs } = arrangeByStoredOrder({
+ items: [
+ ...props.tabs.map((tab) => ({
+ ...tab,
+ onSelect: selectExisting(tab.onSelect),
+ })),
+ ...details.destinations.map((destination) => ({
+ contentFillsRegion: true,
+ label: destination.title,
+ leadingVisual: (
+
+ ),
+ onClose: () => details.close(destination.pluginId),
+ onSelect: () => details.open(destination),
+ renderContent: () => (
+
+ ),
+ statusLabel: null,
+ tab: {
+ id: `marketplace-plugin:${destination.pluginId}`,
+ kind: "marketplace-plugin-detail" as const,
+ },
+ })),
+ ],
+ getId: (tab) => tab.tab.id,
+ storedOrder: details.tabOrder,
+ });
+ return {
+ ...props,
+ activeTab:
+ active === null
+ ? props.activeTab
+ : {
+ id: `marketplace-plugin:${active}`,
+ kind: "marketplace-plugin-detail",
+ },
+ isOpen: active !== null || props.isOpen,
+ splitPanelStateId: active === null ? props.splitPanelStateId : undefined,
+ onClose: selectExisting(props.onClose),
+ onCollapse: selectExisting(props.onCollapse),
+ onOpenNewTab: selectExisting(props.onOpenNewTab),
+ fixedTabs: props.fixedTabs.map((tab) => ({
+ ...tab,
+ onSelect: selectExisting(tab.onSelect),
+ })),
+ tabs,
+ onTabReorder: ({ activeTabId, overTabId }) => {
+ const ids = tabs.map((tab) => tab.tab.id);
+ const from = ids.indexOf(activeTabId);
+ const to = ids.indexOf(overTabId);
+ if (from === -1 || to === -1 || from === to) return;
+ const nextOrder = arrayMove(ids, from, to);
+ details.setTabOrder(nextOrder);
+ const existingIds = new Set(props.tabs.map((tab) => tab.tab.id));
+ if (!existingIds.has(activeTabId)) return;
+ const nextIndex = nextOrder
+ .filter((id) => existingIds.has(id))
+ .indexOf(activeTabId);
+ const existingTarget = props.tabs[nextIndex];
+ if (existingTarget !== undefined) {
+ props.onTabReorder({ activeTabId, overTabId: existingTarget.tab.id });
+ }
+ },
+ };
+}
diff --git a/apps/app/src/components/plugin/plugin-detail-opener.test.tsx b/apps/app/src/components/plugin/plugin-detail-opener.test.tsx
new file mode 100644
index 0000000000..4d554ea9e2
--- /dev/null
+++ b/apps/app/src/components/plugin/plugin-detail-opener.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment jsdom
+import { Suspense } from "react";
+import { cleanup, render } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ openPluginDetailsInWorkspace,
+ PluginDetailOpenerBoundary,
+ usePublishPluginDetailOpener,
+ type PluginDetailOpener,
+} from "./plugin-detail-opener";
+
+const loading = new Promise(() => {});
+const docs = { pluginId: "docs", title: "Docs" };
+const tasks = { pluginId: "tasks", title: "Tasks" };
+
+function LoadedHost({
+ open,
+ focused,
+}: {
+ open: PluginDetailOpener;
+ focused: boolean;
+}) {
+ usePublishPluginDetailOpener(open, focused);
+ return null;
+}
+
+function LazyHost({
+ ready,
+ ...props
+}: {
+ ready: boolean;
+ open: PluginDetailOpener;
+ focused: boolean;
+}) {
+ if (!ready) throw loading;
+ return ;
+}
+
+function Workspace({
+ ready,
+ focused,
+ open,
+ workspace = "plugin-page",
+}: {
+ ready: boolean;
+ focused: boolean;
+ open: PluginDetailOpener;
+ workspace?: string;
+}) {
+ return (
+
+
+
+
+
+ );
+}
+
+afterEach(cleanup);
+
+describe("plugin detail requests while a workspace loads", () => {
+ it("delivers early requests to their original pane even after focus moves", () => {
+ const left = vi.fn(() => true);
+ const right = vi.fn(() => true);
+ const view = render(
+ <>
+
+
+ >,
+ );
+ expect(openPluginDetailsInWorkspace(docs)).toBe(true);
+ expect(openPluginDetailsInWorkspace(tasks)).toBe(true);
+ expect(left).not.toHaveBeenCalled();
+ view.rerender(
+ <>
+
+
+ >,
+ );
+ expect(left.mock.calls).toEqual([[docs], [tasks]]);
+ expect(right).not.toHaveBeenCalled();
+ expect(openPluginDetailsInWorkspace(docs)).toBe(true);
+ expect(right).toHaveBeenCalledExactlyOnceWith(docs);
+ expect(left).toHaveBeenCalledTimes(2);
+ });
+
+ it("discards pending requests when navigating to a different workspace", () => {
+ const open = vi.fn(() => true);
+ const view = render();
+ expect(openPluginDetailsInWorkspace(docs)).toBe(true);
+ view.rerender(
+ ,
+ );
+ expect(open).not.toHaveBeenCalled();
+ expect(openPluginDetailsInWorkspace(tasks)).toBe(true);
+ expect(open).toHaveBeenCalledExactlyOnceWith(tasks);
+ view.unmount();
+ expect(openPluginDetailsInWorkspace(docs)).toBe(false);
+ });
+});
diff --git a/apps/app/src/components/plugin/plugin-detail-opener.tsx b/apps/app/src/components/plugin/plugin-detail-opener.tsx
new file mode 100644
index 0000000000..207870c7f3
--- /dev/null
+++ b/apps/app/src/components/plugin/plugin-detail-opener.tsx
@@ -0,0 +1,86 @@
+import {
+ createContext,
+ useContext,
+ useLayoutEffect,
+ useMemo,
+ useRef,
+ type ReactNode,
+} from "react";
+
+export interface PluginDetailDestination {
+ pluginId: string;
+ title: string;
+}
+
+export type PluginDetailOpener = (
+ destination: PluginDetailDestination,
+) => boolean;
+
+const focusedOpeners = new Map();
+const DeferredPluginDetailOpenerContext = createContext<
+ ((open: PluginDetailOpener) => () => void) | null
+>(null);
+
+export function PluginDetailOpenerBoundary({
+ children,
+ isFocused,
+}: {
+ children: ReactNode;
+ isFocused: boolean;
+}) {
+ const deferred = useMemo(() => {
+ let current: PluginDetailOpener | null = null;
+ const pending: PluginDetailDestination[] = [];
+ return {
+ open(destination: PluginDetailDestination) {
+ if (current !== null) return current(destination);
+ pending.push(destination);
+ return true;
+ },
+ register(open: PluginDetailOpener) {
+ current = open;
+ for (const destination of pending.splice(0)) open(destination);
+ return () => {
+ current = null;
+ };
+ },
+ };
+ }, []);
+ usePublishPluginDetailOpener(deferred.open, isFocused);
+ return (
+
+ {children}
+
+ );
+}
+
+export function openPluginDetailsInWorkspace(
+ destination: PluginDetailDestination,
+): boolean {
+ for (const open of [...focusedOpeners.values()].reverse()) {
+ if (open(destination)) return true;
+ }
+ return false;
+}
+
+export function usePublishPluginDetailOpener(
+ open: PluginDetailOpener,
+ isActive: boolean,
+): void {
+ const registerDeferred = useContext(DeferredPluginDetailOpenerContext);
+ const openRef = useRef(open);
+ useLayoutEffect(() => {
+ openRef.current = open;
+ }, [open]);
+ useLayoutEffect(() => {
+ if (registerDeferred !== null) {
+ return registerDeferred((destination) => openRef.current(destination));
+ }
+ if (!isActive) return;
+ const token = Symbol("plugin-detail-opener");
+ focusedOpeners.set(token, (destination) => openRef.current(destination));
+ return () => {
+ focusedOpeners.delete(token);
+ };
+ }, [isActive, registerDeferred]);
+}
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
index e4fd7ccd41..4ea41a01af 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
@@ -677,6 +677,24 @@ describe("SecondaryPanelLayout", () => {
});
describe("compact sidebar and right panel", () => {
+ it("keeps a newly requested panel open while the sidebar is dismissing", () => {
+ const onClose = vi.fn();
+ const view = renderLayout({
+ isCompactViewport: true,
+ onClose,
+ open: false,
+ renderPanel: createPanelRenderer(),
+ resetKey: "thread-1",
+ });
+ act(() => setCompactSidebarDrawerShowing(true));
+ view.rerenderWith({ open: true });
+ act(() => setCompactSidebarDrawerShowing(false));
+ expect(onClose).not.toHaveBeenCalled();
+ expect(screen.getByTestId("responsive-drawer-shell").dataset.open).toBe(
+ "true",
+ );
+ });
+
it("closes the right panel when the sidebar drawer opens so only one shelf is engaged", () => {
const onClose = vi.fn();
renderLayout({
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
index bafad99c04..94762e9325 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
@@ -1,5 +1,6 @@
import {
useCallback,
+ useContext,
useEffect,
useLayoutEffect,
useMemo,
@@ -36,6 +37,7 @@ import {
isCompactSidebarDrawerShowing,
subscribeCompactSidebarDrawerShowing,
} from "@/components/ui/sidebar-mobile-drawer-visibility";
+import { PluginDetailPanelContext } from "@/components/plugin/plugin-detail-navigation";
const FULL_PANEL_SIZE_PERCENT = 100;
const MAIN_PANEL_MIN_SIZE_PERCENT = 30;
@@ -85,12 +87,26 @@ export function SecondaryPanelLayout({
mainHeader,
main,
collapse,
- renderPanel,
+ renderPanel: renderWorkspacePanel,
renderHostedPanel,
composerHost,
- compactPresentation,
+ compactPresentation: workspaceCompactPresentation,
}: SecondaryPanelLayoutProps) {
const paneContext = useOptionalPaneContext();
+ const pluginDetails = useContext(PluginDetailPanelContext);
+ const isPluginDetailOpen =
+ pluginDetails !== null && pluginDetails.activePluginId !== null;
+ const compactPresentation = isPluginDetailOpen
+ ? "full"
+ : workspaceCompactPresentation;
+ const renderPanel = useCallback(
+ (args: SecondaryPanelRenderArgs) => (
+
+ {renderWorkspacePanel(args)}
+
+ ),
+ [pluginDetails, renderWorkspacePanel],
+ );
const secondaryPanelHost = paneContext?.secondaryPanelHost ?? null;
const renderAsDrawer = useIsCompactViewport();
const sidebarDrawerShowing = useSyncExternalStore(
@@ -98,8 +114,12 @@ export function SecondaryPanelLayout({
isCompactSidebarDrawerShowing,
() => false,
);
+ const previousSidebarDrawerShowing = useRef(sidebarDrawerShowing);
useEffect(() => {
- if (!renderAsDrawer || !open || !sidebarDrawerShowing) return;
+ const sidebarOpened =
+ sidebarDrawerShowing && !previousSidebarDrawerShowing.current;
+ previousSidebarDrawerShowing.current = sidebarDrawerShowing;
+ if (!renderAsDrawer || !open || !sidebarOpened) return;
onClose();
}, [onClose, open, renderAsDrawer, sidebarDrawerShowing]);
const transitionsReady = usePanelCollapseTransitionsReady(
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx
index a70b3c7349..9a1cfbbd88 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx
@@ -195,6 +195,11 @@ describe("ThreadSecondaryPanel compact file content", () => {
);
expect(screen.getByText("Recovered tab body")).toBeTruthy();
+ expect(
+ screen
+ .getByTestId("thread-secondary-panel-top-chrome")
+ .classList.contains("pl-14"),
+ ).toBe(true);
});
it("renders arbitrary fixed-tab content through the shared surface", () => {
@@ -702,6 +707,11 @@ describe("ThreadSecondaryPanel hide control glyph", () => {
const hideControl = view.getByRole("button", { name: "Hide right panel" });
expect(hideControl.querySelector('[data-icon="PanelRight"]')).toBeTruthy();
+ expect(
+ screen
+ .getByTestId("thread-secondary-panel-top-chrome")
+ .classList.contains("pl-14"),
+ ).toBe(false);
});
it("shows the side-panel glyph on a wide viewport", () => {
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
index cc413a2c65..438d64a0f9 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx
@@ -1,3 +1,4 @@
+import { usePluginDetailPanelProps } from "@/components/plugin/plugin-detail-navigation";
import {
type CSSProperties,
type FocusEvent,
@@ -37,6 +38,7 @@ import {
THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT,
} from "./secondaryPanelSizing";
import {
+ getCompactPanelPresentation,
RIGHT_PANEL_TOGGLE_ICON_NAME,
resolveConversationCollapseControl,
} from "./panelToggleControlState";
@@ -210,7 +212,12 @@ export interface ThreadSecondaryPanelProps {
renderAsDrawer: boolean;
}
-export function ThreadSecondaryPanel({
+export function ThreadSecondaryPanel(props: ThreadSecondaryPanelProps) {
+ const panelProps = usePluginDetailPanelProps(props);
+ return ;
+}
+
+function ThreadSecondaryPanelContent({
activeTab,
canUseGitUi,
gitDiffTabStatus,
@@ -252,6 +259,12 @@ export function ThreadSecondaryPanel({
() => tabs.filter((tab) => tab.isHidden !== true),
[tabs],
);
+ const reservesCompactSidebarToggle =
+ renderAsDrawer &&
+ getCompactPanelPresentation(
+ activeTab?.kind,
+ fixedTabs[0]?.tab.kind ?? visibleTabs[0]?.tab.kind,
+ ) === "full";
const activeRenderableTab =
tabs.find((tab) => tab.tab.id === activeTab?.id) ??
(activeTab === null && fixedTabs.length === 0 ? visibleTabs[0] : undefined);
@@ -711,6 +724,7 @@ export function ThreadSecondaryPanel({
className={cn(
CHROME_ROW_CLASS,
"min-w-0 justify-between gap-2 px-4",
+ reservesCompactSidebarToggle && "pl-14",
usesDesktopChrome && usesWindowChrome && MACOS_WINDOW_DRAG_CLASS,
usesDesktopChrome &&
usesWindowChrome &&
diff --git a/apps/app/src/components/ui/app-overlay-layers.ts b/apps/app/src/components/ui/app-overlay-layers.ts
index 90e8e4e9d6..4170cc8a18 100644
--- a/apps/app/src/components/ui/app-overlay-layers.ts
+++ b/apps/app/src/components/ui/app-overlay-layers.ts
@@ -3,5 +3,6 @@ export const APP_OVERLAY_LAYER = {
secondaryPanelDismiss: 40,
sidebarTrigger: 44,
secondaryPanelFullPage: 45,
+ compactSidebarTrigger: 46,
sharedPortaledOverlay: 50,
} as const;
diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx
index 5586ea46c6..a9cfd63197 100644
--- a/apps/app/src/components/ui/app-route-anchor.tsx
+++ b/apps/app/src/components/ui/app-route-anchor.tsx
@@ -18,6 +18,8 @@ import { isRoutePath, resolveRouteHref } from "@/lib/route-paths";
import { getDesktopBrowserApi } from "@/lib/bb-desktop";
import { openPaneContentInSplit } from "@/lib/split-layout/openPaneContentInSplit";
import { paneContentForPathname } from "@/views/thread-detail/splitThreadNavigation";
+import { useOptionalPaneContext } from "@/views/thread-detail/PaneContext";
+import { usePublishPluginDetailOpener } from "@/components/plugin/plugin-detail-opener";
interface RouteNavigationProviderProps {
children: ReactNode;
@@ -159,6 +161,11 @@ export function PluginDetailRouteNavigationProvider({
children: ReactNode;
onOpenPluginDetail: (pluginId: string) => boolean;
}) {
+ const pane = useOptionalPaneContext();
+ usePublishPluginDetailOpener(
+ ({ pluginId }) => onOpenPluginDetail(pluginId),
+ pane?.isFocused ?? true,
+ );
return (
{children}
diff --git a/apps/app/src/views/RootComposeView.tsx b/apps/app/src/views/RootComposeView.tsx
index 0f343e0008..d9f33b1c53 100644
--- a/apps/app/src/views/RootComposeView.tsx
+++ b/apps/app/src/views/RootComposeView.tsx
@@ -181,6 +181,10 @@ import {
useAppCommandShortcut,
} from "@/components/commands/AppCommandProvider";
import { useOptionalPaneContext } from "./thread-detail/PaneContext";
+import {
+ PluginDetailPanelContext,
+ usePluginDetailPanelState,
+} from "@/components/plugin/plugin-detail-navigation";
import { RootComposePanelCommandHandlers } from "./RootComposePanelCommandHandlers";
import {
ROOT_COMPOSE_FIXED_PANEL_STATE_ID,
@@ -650,6 +654,10 @@ function RootComposeSurface({
}: RootComposeSurfaceProps) {
const paneContext = useOptionalPaneContext();
const isFocusedPane = paneContext?.isFocused ?? true;
+ const pluginDetails = usePluginDetailPanelState(
+ ROOT_COMPOSE_FIXED_PANEL_STATE_ID,
+ isFocusedPane,
+ );
const location = useLocation();
const navigate = useNavigate();
const isPointerCoarse = usePointerCoarse();
@@ -910,9 +918,11 @@ function RootComposeSurface({
isCompactViewport,
threadId: ROOT_COMPOSE_FIXED_PANEL_STATE_ID,
});
- const isSecondaryPanelOpen = isCompactViewport
+ const isWorkspacePanelOpen = isCompactViewport
? secondaryPanelDrawerVisibility.isDrawerVisible
: isPersistedSecondaryPanelOpen;
+ const isSecondaryPanelOpen =
+ isWorkspacePanelOpen || pluginDetails.activePluginId !== null;
const touchFixedPanelTabsState = useTouchFixedPanelTabsState(
ROOT_COMPOSE_FIXED_PANEL_STATE_ID,
null,
@@ -1138,7 +1148,7 @@ function RootComposeSurface({
openTab({ kind: "new-tab" });
}, [closeRootSecondaryPanel, isPersistedSecondaryPanelOpen, openTab]);
const {
- closePanel: closeSecondaryPanel,
+ closePanel: closeWorkspacePanel,
openCompactDrawer,
openHostFile,
openStorageFile,
@@ -1157,6 +1167,11 @@ function RootComposeSurface({
openPersistedWorkspaceFile,
togglePersistedPanel: toggleRootPersistedSecondaryPanel,
});
+ const dismissPluginDetails = pluginDetails.dismiss;
+ const closeSecondaryPanel = useCallback(() => {
+ dismissPluginDetails();
+ closeWorkspacePanel();
+ }, [dismissPluginDetails, closeWorkspacePanel]);
const handleOpenLiveFilePreview = useCallback(
(intent: AppFilePreviewIntent): boolean => {
const normalized = normalizeExperimentalFileOpenOptions(intent);
@@ -1505,6 +1520,10 @@ function RootComposeSurface({
],
);
const handleCloseWindowRequest = useCallback(() => {
+ if (pluginDetails.activePluginId !== null) {
+ pluginDetails.close(pluginDetails.activePluginId);
+ return true;
+ }
if (!isSecondaryPanelOpen) {
return false;
}
@@ -1527,6 +1546,7 @@ function RootComposeSurface({
closeTab,
handleCloseTerminalTab,
isSecondaryPanelOpen,
+ pluginDetails,
]);
const [openLinksInAppBrowser] = useOpenLinksInAppBrowserPreference();
const desktopBrowserAvailable = isDesktopBrowserAvailable();
@@ -1958,7 +1978,7 @@ function RootComposeSurface({
});
return (
- <>
+
- >
+
);
}
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
index 022b3e482d..16a76f0d98 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx
@@ -672,7 +672,7 @@ describe("SplitThreadArea", () => {
expect(host.dataset.pluginId).toBe("docs");
expect(host.dataset.panelPath).toBe("docs");
expect(host.dataset.flushPageInsets).toBe("true");
- expect(host.dataset.pluginDetailTabsEnabled).toBe("false");
+ expect(host.dataset.pluginDetailTabsEnabled).toBe("true");
});
it("preserves detail state within the Guide and clears it for another plugin page", async () => {
diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
index 7103336b14..121b62c52f 100644
--- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx
+++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx
@@ -112,6 +112,7 @@ import {
} from "@/components/ui/context-selection";
import { PaneMaximizeButton } from "./PaneMaximizeButton";
import { wsManager } from "@/lib/ws";
+import { PluginDetailOpenerBoundary } from "@/components/plugin/plugin-detail-opener";
const LazyPluginPanelRightPanelHost = lazy(() =>
import("@/components/plugin/PluginPanelRightPanelHost").then(
@@ -119,9 +120,6 @@ const LazyPluginPanelRightPanelHost = lazy(() =>
),
);
-const PLUGIN_GUIDE_PLUGIN_ID = "plugin-api-docs";
-const PLUGIN_GUIDE_PANEL_PATH = "plugin-api";
-
const LazyPluginDetailPaneView = lazy(() =>
import("@/views/ToolsView").then(({ PluginDetailPaneView }) => ({
default: PluginDetailPaneView,
@@ -147,19 +145,18 @@ function PluginPagePanelHost({
pluginId: string;
subPath: string;
}) {
+ const pane = useOptionalPaneContext();
return (
-
-
- {children}
-
-
+
+
+
+ {children}
+
+
+
);
}
diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
index 1626a96610..89e1ffa1f5 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx
@@ -44,6 +44,10 @@ import type { WorkspaceOpenTarget } from "@bb/host-daemon-contract";
import { appToast } from "@/components/ui/app-toast";
import { copyToClipboardWithToast } from "@/lib/clipboard";
import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel";
+import {
+ PluginDetailPanelContext,
+ usePluginDetailPanelState,
+} from "@/components/plugin/plugin-detail-navigation";
import { useForkThreadFromMessage } from "@/hooks/useForkThreadFromMessage";
import { isThreadForkable } from "@bb/client-core";
import { useRequestEnvironmentAction } from "../../hooks/mutations/environment-mutations";
@@ -586,9 +590,12 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
isCompactViewport: renderSecondaryPanelAsDrawer,
threadId,
});
- const isSecondaryPanelOpen = renderSecondaryPanelAsDrawer
+ const pluginDetails = usePluginDetailPanelState(threadId, isFocused);
+ const isWorkspacePanelOpen = renderSecondaryPanelAsDrawer
? secondaryPanelDrawerVisibility.isDrawerVisible
: isPersistedSecondaryPanelOpen;
+ const isSecondaryPanelOpen =
+ isWorkspacePanelOpen || pluginDetails.activePluginId !== null;
const touchFixedPanelTabsState = useTouchFixedPanelTabsState(
threadId,
threadId,
@@ -1252,7 +1259,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
threadId,
});
const {
- closePanel: closeSecondaryPanel,
+ closePanel: closeWorkspacePanel,
openCommitDiff: openGitDiffCommitDestination,
openCompactDrawer,
openDiffFile: openGitDiffFileDestination,
@@ -1261,7 +1268,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
openPanel: openFixedViewDestination,
openStorageFile,
openWorkspaceFile,
- togglePanel: toggleSecondaryPanel,
+ togglePanel: toggleWorkspacePanel,
} = useThreadSecondaryPanelVisibility({
closePersistedPanel: closeThreadSecondaryPanel,
drawerVisibility: secondaryPanelDrawerVisibility,
@@ -1276,6 +1283,15 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
openPersistedWorkspaceFile,
togglePersistedPanel: toggleDefaultPersistedSecondaryPanel,
});
+ const dismissPluginDetails = pluginDetails.dismiss;
+ const closeSecondaryPanel = useCallback(() => {
+ dismissPluginDetails();
+ closeWorkspacePanel();
+ }, [dismissPluginDetails, closeWorkspacePanel]);
+ const toggleSecondaryPanel = useCallback(() => {
+ if (pluginDetails.activePluginId !== null) closeSecondaryPanel();
+ else toggleWorkspacePanel();
+ }, [pluginDetails.activePluginId, closeSecondaryPanel, toggleWorkspacePanel]);
const fixedTabDestinations = useMemo(
() => [
createThreadInfoFixedTabDestination(() =>
@@ -1670,6 +1686,10 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
[closeTerminal, removeFixedTerminalTab, threadId],
);
const handleCloseWindowRequest = useCallback(() => {
+ if (pluginDetails.activePluginId !== null) {
+ pluginDetails.close(pluginDetails.activePluginId);
+ return true;
+ }
if (!isSecondaryPanelOpen) {
return false;
}
@@ -1692,6 +1712,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
closeTab,
handleCloseTerminalTab,
isSecondaryPanelOpen,
+ pluginDetails,
]);
useAppCommandHandler("panel.toggle", () => {
if (!isFocused) return false;
@@ -3001,7 +3022,9 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) {
- {threadDetailContent}
+
+ {threadDetailContent}
+
>