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..e9881843a0 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"; @@ -43,8 +45,7 @@ vi.mock("./AppLayoutSidebar", async () => { vi.mock("@/hooks/queries/system-queries", () => ({ useSystemConfig: () => ({ data: { - experiments: { - }, + experiments: {}, generalSettings: defaultAppSettings, keybindings: [ { @@ -198,10 +199,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 14cb460916..6c4b0c5634 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 (
{ ); 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 4f1077dbbd..438d64a0f9 100644 --- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx +++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.tsx @@ -38,6 +38,7 @@ import { THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT, } from "./secondaryPanelSizing"; import { + getCompactPanelPresentation, RIGHT_PANEL_TOGGLE_ICON_NAME, resolveConversationCollapseControl, } from "./panelToggleControlState"; @@ -258,6 +259,12 @@ function ThreadSecondaryPanelContent({ () => 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); @@ -717,6 +724,7 @@ function ThreadSecondaryPanelContent({ 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;