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 {useLocation().pathname}; + const location = useLocation(); + const navigate = useNavigate(); + return ( + <> + {location.pathname} + + + + ); } 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 ( <> -