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;