diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx index c0cb9c4938..c101de4804 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.test.tsx @@ -54,6 +54,7 @@ import { findPaneByContent, type SplitLayout, } from "@/lib/split-layout"; +import { usePublishPluginDetailOpener } from "./plugin-detail-navigation"; vi.mock("@/components/ui/app-toast", () => ({ appToast: { dismiss: vi.fn(), @@ -521,6 +522,28 @@ describe("PluginNavSidebarItems", () => { ); }); + 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([ { compactViewport: false, pluginId: "docs", title: "Docs" }, { compactViewport: true, pluginId: "docs", title: "Docs" }, diff --git a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx index 416cbb3878..34d41e10d1 100644 --- a/apps/app/src/components/plugin/PluginNavSidebarItems.tsx +++ b/apps/app/src/components/plugin/PluginNavSidebarItems.tsx @@ -108,6 +108,7 @@ import { togglePluginNavPanelVisibility, } from "./pluginNavSidebarOrder"; import { haveSameOrder, reorderStoredOrder } from "@/lib/stored-order"; +import { openPluginDetailsInWorkspace } from "./plugin-detail-navigation"; const MORE_TRIGGER_TEST_ID = "sidebar-navigation-more-trigger"; @@ -1206,6 +1207,13 @@ function PluginNavSidebarItem({ onOpenInSplit={splitEnabled ? openInSplit : undefined} onOpenDetails={() => { onNavigate?.(); + if ( + openPluginDetailsInWorkspace({ + pluginId: chrome.pluginId, + title: chrome.title, + }) + ) + return; void navigate(getPluginDetailRoutePath({ pluginId: chrome.pluginId })); }} onDisable={() => onDisable(row)} diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx index 40c641d15b..aa8c8a4f9c 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, @@ -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..146c7d0555 --- /dev/null +++ b/apps/app/src/components/plugin/plugin-detail-navigation.test.tsx @@ -0,0 +1,159 @@ +// @vitest-environment jsdom +import { act, cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { ThreadSecondaryPanelProps } from "@/components/secondary-panel/ThreadSecondaryPanel"; +import { + openPluginDetailsInWorkspace, + PluginDetailPanelContext, + usePluginDetailPanelProps, + usePluginDetailPanelState, +} from "./plugin-detail-navigation"; + +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 }: { id: string }) { + const props = usePluginDetailPanelProps(baseProps); + return ( +
+ {props.tabs.map((tab) => ( +
+ + +
+ ))} + +
+ ); +} + +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("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..3667dae9a3 --- /dev/null +++ b/apps/app/src/components/plugin/plugin-detail-navigation.tsx @@ -0,0 +1,186 @@ +import { + createContext, + lazy, + Suspense, + useCallback, + useContext, + useLayoutEffect, + useMemo, + useRef, + useState, + type Key, +} from "react"; +import { PluginIcon } from "./PluginIcon"; +import type { ThreadSecondaryPanelProps } from "@/components/secondary-panel/ThreadSecondaryPanel"; +import { SecondaryPanelContentSkeleton } from "@/components/secondary-panel/lazySecondaryPanelComponents"; + +interface PluginDetailDestination { + pluginId: string; + title: string; +} + +type PluginDetailOpener = (destination: PluginDetailDestination) => boolean; + +const focusedOpeners = new Map(); + +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 openRef = useRef(open); + useLayoutEffect(() => { + openRef.current = open; + }, [open]); + useLayoutEffect(() => { + if (!isActive) return; + const token = Symbol("plugin-detail-opener"); + focusedOpeners.set(token, (destination) => openRef.current(destination)); + return () => { + focusedOpeners.delete(token); + }; + }, [isActive]); +} + +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; +} + +export const PluginDetailPanelContext = + createContext(null); + +export function usePluginDetailPanelState(resetKey: Key, isFocused: boolean) { + const [destinations, setDestinations] = useState( + [], + ); + const [activePluginId, setActivePluginId] = useState(null); + useLayoutEffect(() => { + // oxlint-disable-next-line react/set-state-in-effect + setDestinations([]); + // oxlint-disable-next-line react/set-state-in-effect + setActivePluginId(null); + }, [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 = destinations.findIndex( + (entry) => entry.pluginId === pluginId, + ); + const remaining = destinations.filter( + (entry) => entry.pluginId !== pluginId, + ); + setDestinations(remaining); + if (activePluginId === pluginId) { + setActivePluginId( + remaining[Math.min(index, remaining.length - 1)]?.pluginId ?? null, + ); + } + }, + [activePluginId, destinations], + ); + usePublishPluginDetailOpener(open, isFocused); + return useMemo( + () => ({ activePluginId, destinations, dismiss, close, open }), + [activePluginId, destinations, dismiss, close, open], + ); +} + +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(); + }; + 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: [ + ...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, + }, + })), + ], + }; +} 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.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx index cc413a2c65..4f1077dbbd 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, @@ -210,7 +211,12 @@ export interface ThreadSecondaryPanelProps { renderAsDrawer: boolean; } -export function ThreadSecondaryPanel({ +export function ThreadSecondaryPanel(props: ThreadSecondaryPanelProps) { + const panelProps = usePluginDetailPanelProps(props); + return ; +} + +function ThreadSecondaryPanelContent({ activeTab, canUseGitUi, gitDiffTabStatus, diff --git a/apps/app/src/components/ui/app-route-anchor.tsx b/apps/app/src/components/ui/app-route-anchor.tsx index 5586ea46c6..365d4cdf89 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-navigation"; 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..bb8fe7d723 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); @@ -1958,7 +1973,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..ed9fd9eb83 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -119,9 +119,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, @@ -152,10 +149,7 @@ function PluginPagePanelHost({ {children} diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 1626a96610..db22538494 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(() => @@ -3001,7 +3017,9 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { - {threadDetailContent} + + {threadDetailContent} +