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 61dfa90ee5..9a02bb5047 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
@@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppLayout } from "./AppLayout";
import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers";
import {
- setCompactSecondaryPanelShelfShowing,
+ setCompactSecondaryPanelPresentation,
} from "@/components/ui/secondary-panel-shelf-visibility";
const viewportState = vi.hoisted(() => ({ compact: false }));
@@ -167,12 +167,12 @@ function renderPluginPanelRoute(): void {
describe("AppLayout plugin panel header", () => {
beforeEach(() => {
viewportState.compact = false;
- setCompactSecondaryPanelShelfShowing(false);
+ setCompactSecondaryPanelPresentation("closed");
});
afterEach(() => {
cleanup();
- setCompactSecondaryPanelShelfShowing(false);
+ setCompactSecondaryPanelPresentation("closed");
vi.clearAllMocks();
});
@@ -197,11 +197,13 @@ describe("AppLayout plugin panel header", () => {
expect(trigger.style.zIndex).toBe(
String(APP_OVERLAY_LAYER.sidebarTrigger),
);
+ act(() => setCompactSecondaryPanelPresentation("shelf"));
+ expect(screen.queryByTestId("app-sidebar-trigger-overlay")).toBeNull();
- act(() => setCompactSecondaryPanelShelfShowing(true));
+ act(() => setCompactSecondaryPanelPresentation("full"));
expect(screen.queryByTestId("app-sidebar-trigger-overlay")).toBeNull();
- act(() => setCompactSecondaryPanelShelfShowing(false));
+ act(() => setCompactSecondaryPanelPresentation("closed"));
expect(screen.getByTestId("app-sidebar-trigger-overlay")).not.toBeNull();
});
});
diff --git a/apps/app/src/components/layout/AppLayout.tsx b/apps/app/src/components/layout/AppLayout.tsx
index 51206da670..9b892c2548 100644
--- a/apps/app/src/components/layout/AppLayout.tsx
+++ b/apps/app/src/components/layout/AppLayout.tsx
@@ -51,7 +51,7 @@ 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 {
- isCompactSecondaryPanelShelfShowing,
+ getCompactSecondaryPanelPresentation,
subscribeCompactSecondaryPanelShelfShowing,
} from "@/components/ui/secondary-panel-shelf-visibility";
import { ProjectPathDialog } from "@/components/dialogs/ProjectPathDialog";
@@ -212,13 +212,16 @@ function SidebarTriggerOverlay({
usesDesktopChrome,
}: SidebarTriggerOverlayProps) {
const isCompactViewport = useIsCompactViewport();
- const compactSecondaryPanelShelfShowing = useSyncExternalStore(
+ const compactSecondaryPanelPresentation = useSyncExternalStore(
subscribeCompactSecondaryPanelShelfShowing,
- isCompactSecondaryPanelShelfShowing,
- () => false,
+ getCompactSecondaryPanelPresentation,
+ () => "closed",
);
const shortcut = useAppCommandShortcut("sidebar.toggle");
- if (isCompactViewport && compactSecondaryPanelShelfShowing) {
+ if (
+ isCompactViewport &&
+ compactSecondaryPanelPresentation !== "closed"
+ ) {
return null;
}
const triggerProps = {
diff --git a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
index 608f8b77e8..109981dbba 100644
--- a/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
+++ b/apps/app/src/components/plugin/PluginPanelRightPanelHost.tsx
@@ -21,7 +21,10 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip";
import { useAppCommandHandler } from "@/components/commands/AppCommandProvider";
import { PluginIcon } from "@/components/plugin/PluginIcon";
import { PluginSlotMount } from "@/components/plugin/PluginSlotMount";
-import { RIGHT_PANEL_TOGGLE_ICON_NAME } from "@/components/secondary-panel/panelToggleControlState";
+import {
+ getCompactPanelPresentation,
+ RIGHT_PANEL_TOGGLE_ICON_NAME,
+} from "@/components/secondary-panel/panelToggleControlState";
import { SecondaryPanelLayout } from "@/components/secondary-panel/SecondaryPanelLayout";
import {
LazyBrowserTabDeck,
@@ -971,6 +974,11 @@ export function PluginPanelRightPanelHost({
mainPanelId={`plugin-panel-main-${panelHostId}`}
main={children}
composerHost={null}
+ compactPresentation={getCompactPanelPresentation(
+ activeTab?.kind,
+ fixedTabs[0]?.tab.kind ??
+ panelTabs.find((tab) => tab.isHidden !== true)?.tab.kind,
+ )}
renderPanel={renderPanel}
/>
diff --git a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.stories.tsx b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.stories.tsx
new file mode 100644
index 0000000000..5a2d6cf021
--- /dev/null
+++ b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.stories.tsx
@@ -0,0 +1,271 @@
+import { useState, type ReactNode } from "react";
+import { Icon } from "@bb/shared-ui/icon";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import {
+ createGitDiffFixedPanelTab,
+ createThreadInfoFixedPanelTab,
+ type HostFilePreviewFixedPanelTab,
+ type SecondaryFixedPanelTab,
+} from "@/lib/fixed-panel-tabs-state";
+import {
+ CompactHomePage,
+ MOBILE_RECENTS_VISIBILITY_CLASS,
+ MobileRecentsVisibilityStyle,
+} from "@/views/mobile-home-story-fixtures";
+import { CompactSecondaryPanelShelf } from "./CompactSecondaryPanelShelf";
+import {
+ ThreadSecondaryPanel,
+ type SecondaryPanelRenderableTab,
+} from "./ThreadSecondaryPanel";
+import { ThreadMetadataContent } from "./ThreadMetadataContent";
+import { baseProps as baseMetadataProps } from "./ThreadMetadataContent.fixtures";
+import { FilePreview } from "./FilePreview";
+import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import { threadListQueryKey } from "@/hooks/queries/query-keys";
+
+export default {
+ title: "right-panel/Compact shelf",
+};
+
+const noop = () => {};
+
+const STORY_FILE_PATH = "apps/app/src/views/RootComposeCompactHome.tsx";
+const STORY_FILE_SOURCE = `export function RootComposeCompactHome({
+ children,
+ composer,
+}: RootComposeCompactHomeProps) {
+ const { regionRef, composerRef } = useCompactHomeMetrics();
+ return (
+
+ {children}
+
{composer}
+
+ );
+}`;
+
+function createStoryFileTab(path: string): HostFilePreviewFixedPanelTab {
+ return {
+ environmentId: "env_story",
+ hostId: "host_story",
+ id: `host-file-preview:${encodeURIComponent(path)}:thread%3Athr_story%3Aenvironment%3Aenv_story`,
+ kind: "host-file-preview",
+ lineRange: null,
+ path,
+ threadId: "thr_story",
+ };
+}
+
+const fileTab = createStoryFileTab(STORY_FILE_PATH);
+
+const MANY_TAB_PATHS: string[] = [
+ STORY_FILE_PATH,
+ "apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx",
+ "apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx",
+ "apps/app/src/hooks/queries/thread-queries.ts",
+ "apps/app/src/components/ui/sidebar.tsx",
+ "apps/app/src/components/ui/theme.css",
+ "apps/app/src/app.css",
+ "package.json",
+ "README.md",
+ "apps/server/src/services/providers/provider-registry.ts",
+ "an-unusually-long-component-filename-that-must-truncate.tsx",
+]
+
+function StoryFileContent({ path }: { path: string }) {
+ return (
+
+ );
+}
+
+interface ShelfPanelProps {
+ activeTab: SecondaryFixedPanelTab;
+ onSelectTab: (tab: SecondaryFixedPanelTab) => void;
+ onClose: () => void;
+ filePaths?: string[];
+}
+
+function ShelfPanel({
+ activeTab,
+ onSelectTab,
+ onClose,
+ filePaths = [STORY_FILE_PATH],
+}: ShelfPanelProps) {
+ const tabs: SecondaryPanelRenderableTab[] = filePaths.map((path, index) => {
+ const tab = path === STORY_FILE_PATH ? fileTab : createStoryFileTab(path);
+ return {
+ label: path.split("/").at(-1) ?? path,
+ isPinned: index === 0 && filePaths.length > 1,
+ leadingVisual: ,
+ statusLabel: null,
+ onSelect: () => onSelectTab(tab),
+ onClose: noop,
+ renderContent: () => ,
+ tab,
+ };
+ });
+ const infoTab = createThreadInfoFixedPanelTab();
+ const diffTab = createGitDiffFixedPanelTab();
+
+ return (
+ }
+ tabs={tabs}
+ fixedTabs={[
+ {
+ ariaLabel: "Show thread info panel",
+ label: "Info",
+ leadingVisual: ,
+ onSelect: () => onSelectTab(infoTab),
+ tab: infoTab,
+ title: "Thread info",
+ },
+ {
+ ariaLabel: "Show diff panel",
+ label: "Diff",
+ leadingVisual: ,
+ onSelect: () => onSelectTab(diffTab),
+ tab: diffTab,
+ title: "Diff",
+ },
+ ]}
+ onPanelFocus={noop}
+ onCollapse={noop}
+ onClose={onClose}
+ onTabReorder={noop}
+ onOpenNewTab={noop}
+ isConversationCollapsed={false}
+ onToggleConversationCollapse={noop}
+ renderAsDrawer
+ inlinePanelToggle="hidden"
+ showConversationCollapseControl={false}
+ />
+ );
+}
+
+function Stage({ children }: { children: ReactNode }) {
+ const [queryClient] = useState(() => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(
+ threadListQueryKey({
+ projectId: baseMetadataProps.thread.projectId,
+ sourceThreadId: baseMetadataProps.thread.id,
+ originKind: "fork",
+ archived: false,
+ }),
+ [],
+ );
+ return client;
+ });
+ return (
+
+
+
+
+
+
+
+ {children}
+
+
+
+ );
+}
+
+function ShelfStory({
+ initialPresentation,
+}: {
+ initialPresentation: "shelf" | "full";
+}) {
+ const [activeTab, setActiveTab] = useState(
+ initialPresentation === "shelf" ? createThreadInfoFixedPanelTab() : fileTab,
+ );
+ const [open, setOpen] = useState(true);
+ const presentation = activeTab.kind === "thread-info" ? "shelf" : "full";
+
+ return (
+
+ setOpen(false)}
+ presentation={presentation}
+ srLabel="Right panel"
+ >
+ setOpen(false)}
+ />
+
+
+ );
+}
+
+export function Shelf() {
+ return ;
+}
+
+export function FullPage() {
+ return ;
+}
+
+export function ManyTabs() {
+ const [activeTab, setActiveTab] = useState(fileTab);
+ const presentation = activeTab.kind === "thread-info" ? "shelf" : "full";
+ return (
+
+
+
+
+
+ );
+}
+
+export function Closed() {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx
index 3f4bbb9bc8..d6adec5f5d 100644
--- a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx
+++ b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.test.tsx
@@ -4,19 +4,24 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { CompactSecondaryPanelShelf } from "./CompactSecondaryPanelShelf";
-import { isCompactSecondaryPanelShelfShowing } from "@/components/ui/secondary-panel-shelf-visibility";
import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers";
+import { getCompactSecondaryPanelPresentation } from "@/components/ui/secondary-panel-shelf-visibility";
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
-function renderShelf(open: boolean, onClose = vi.fn()) {
+function renderShelf(
+ open: boolean,
+ presentation: "shelf" | "full" = "shelf",
+ onClose = vi.fn(),
+) {
const view = render(
@@ -35,7 +40,52 @@ describe("CompactSecondaryPanelShelf", () => {
expect(shelf.style.zIndex).toBe(String(APP_OVERLAY_LAYER.secondaryPanel));
expect(shelf.className).toContain("w-(--secondary-panel-width-mobile)");
expect(shelf.className).not.toContain("bottom-0");
- expect(shelf.className).not.toContain("rounded-t-xl");
+ });
+
+ it("fills the viewport for a full-page tab and keeps the shelf width otherwise", () => {
+ const { rerender } = renderShelf(true, "shelf");
+ const shelf = screen.getByTestId("secondary-panel-shelf");
+ expect(shelf.dataset.state).toBe("shelf");
+ expect(shelf.className).toContain("data-[state=full]:w-full");
+
+ rerender(
+
+
+ ,
+ );
+ expect(screen.getByTestId("secondary-panel-shelf").dataset.state).toBe(
+ "full",
+ );
+ });
+
+ it("stacks the full page panel above app chrome and below shared overlays", () => {
+ renderShelf(true, "full");
+
+ const shelf = screen.getByTestId("secondary-panel-shelf");
+ expect(shelf.style.zIndex).toBe(
+ String(APP_OVERLAY_LAYER.secondaryPanelFullPage),
+ );
+ expect(APP_OVERLAY_LAYER.secondaryPanelFullPage).toBeGreaterThan(
+ APP_OVERLAY_LAYER.sidebarTrigger,
+ );
+ expect(APP_OVERLAY_LAYER.sharedPortaledOverlay).toBeGreaterThan(
+ APP_OVERLAY_LAYER.secondaryPanelFullPage,
+ );
+ });
+
+ it("stops the dismiss layer from swallowing taps once the panel is full page", () => {
+ renderShelf(true, "full");
+
+ const dismiss = screen.getByTestId("secondary-panel-shelf-dismiss");
+ expect(dismiss.className).toContain(
+ "data-[state=full]:pointer-events-none",
+ );
+ expect(dismiss.className).toContain("data-[state=full]:-translate-x-full");
});
it("leaves the page undimmed and dismisses from the exposed strip", () => {
@@ -50,7 +100,7 @@ describe("CompactSecondaryPanelShelf", () => {
);
expect(dismiss.className).toContain("bg-transparent");
expect(dismiss.className).toContain(
- "data-[state=open]:-translate-x-(--secondary-panel-width-mobile)",
+ "data-[state=shelf]:-translate-x-(--secondary-panel-width-mobile)",
);
fireEvent.click(dismiss);
@@ -61,6 +111,7 @@ describe("CompactSecondaryPanelShelf", () => {
renderShelf(false);
const shelf = screen.getByTestId("secondary-panel-shelf");
+ expect(shelf.dataset.state).toBe("closed");
expect(shelf.className).toContain("data-[state=closed]:invisible");
expect(shelf.className).toContain(
"data-[state=closed]:[transition:visibility_0s_linear_220ms]",
@@ -74,7 +125,12 @@ describe("CompactSecondaryPanelShelf", () => {
).toBe(true);
rerender(
-
+
,
);
@@ -83,23 +139,36 @@ describe("CompactSecondaryPanelShelf", () => {
).toBe(false);
});
- it("publishes open state so the page knows to displace", () => {
- const { rerender, unmount } = renderShelf(true);
- expect(isCompactSecondaryPanelShelfShowing()).toBe(true);
+ it("publishes the presentation so the page knows how far to displace", () => {
+ const { rerender, unmount } = renderShelf(true, "shelf");
+ expect(getCompactSecondaryPanelPresentation()).toBe("shelf");
+
+ rerender(
+
+
+ ,
+ );
+ expect(getCompactSecondaryPanelPresentation()).toBe("full");
rerender(
,
);
- expect(isCompactSecondaryPanelShelfShowing()).toBe(false);
+ expect(getCompactSecondaryPanelPresentation()).toBe("closed");
unmount();
- expect(isCompactSecondaryPanelShelfShowing()).toBe(false);
+ expect(getCompactSecondaryPanelPresentation()).toBe("closed");
});
it("closes on Escape only while open", () => {
@@ -108,7 +177,12 @@ describe("CompactSecondaryPanelShelf", () => {
expect(onClose).not.toHaveBeenCalled();
rerender(
-
+
,
);
@@ -127,6 +201,7 @@ describe("CompactSecondaryPanelShelf", () => {
setOpen(false)}
+ presentation="shelf"
srLabel="Right panel"
>
diff --git a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx
index acba38954e..caee5ab4d5 100644
--- a/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx
+++ b/apps/app/src/components/secondary-panel/CompactSecondaryPanelShelf.tsx
@@ -11,10 +11,13 @@ import { createPortal } from "react-dom";
import { cn } from "@bb/shared-ui/lib/utils";
import { usePersistentOverlayFocus } from "@bb/shared-ui/responsive-overlay";
import { APP_OVERLAY_LAYER } from "@/components/ui/app-overlay-layers";
-import { setCompactSecondaryPanelShelfShowing } from "@/components/ui/secondary-panel-shelf-visibility";
+import {
+ setCompactSecondaryPanelPresentation,
+ type CompactSecondaryPanelPresentation,
+} from "@/components/ui/secondary-panel-shelf-visibility";
const SHELF_TRANSITION_CLASS =
- "[transition:translate_220ms_cubic-bezier(0.32,0.72,0,1)]";
+ "[transition:translate_220ms_cubic-bezier(0.32,0.72,0,1),width_220ms_cubic-bezier(0.32,0.72,0,1)]";
const SHELF_SETTLE_MS = 220;
interface CompactSecondaryPanelShelfProps {
@@ -22,6 +25,7 @@ interface CompactSecondaryPanelShelfProps {
onClose: () => void;
onContentAnimationEnd?: (open: boolean) => void;
open: boolean;
+ presentation: Exclude;
srLabel?: string;
}
@@ -30,10 +34,12 @@ export function CompactSecondaryPanelShelf({
onClose,
onContentAnimationEnd,
open,
+ presentation,
srLabel,
}: CompactSecondaryPanelShelfProps) {
const labelId = useId();
const panelRef = useRef(null);
+ const state = !open ? "closed" : presentation;
const onCloseRef = useRef(onClose);
useLayoutEffect(() => {
onCloseRef.current = onClose;
@@ -51,9 +57,9 @@ export function CompactSecondaryPanelShelf({
});
useEffect(() => {
- setCompactSecondaryPanelShelfShowing(open);
- return () => setCompactSecondaryPanelShelfShowing(false);
- }, [open]);
+ setCompactSecondaryPanelPresentation(state);
+ return () => setCompactSecondaryPanelPresentation("closed");
+ }, [state]);
useEffect(() => {
if (onContentAnimationEnd === undefined) return;
@@ -73,14 +79,15 @@ export function CompactSecondaryPanelShelf({
@@ -94,11 +101,18 @@ export function CompactSecondaryPanelShelf({
inert={!open}
data-secondary-panel-shelf=""
data-testid="secondary-panel-shelf"
- data-state={open ? "open" : "closed"}
- style={{ zIndex: APP_OVERLAY_LAYER.secondaryPanel }}
+ data-state={state}
+ style={{
+ zIndex:
+ state === "full"
+ ? APP_OVERLAY_LAYER.secondaryPanelFullPage
+ : APP_OVERLAY_LAYER.secondaryPanel,
+ }}
className={cn(
- "fixed inset-y-0 right-0 flex h-(--bb-shell-height) w-(--secondary-panel-width-mobile) select-none flex-col overflow-hidden border-l border-border-seam bg-background outline-none",
- "[transition:visibility_0s_linear_0s] data-[state=closed]:invisible data-[state=closed]:[transition:visibility_0s_linear_220ms]",
+ "fixed inset-y-0 right-0 flex h-(--bb-shell-height) select-none flex-col overflow-hidden border-l border-border-seam bg-background outline-none",
+ "w-(--secondary-panel-width-mobile) data-[state=full]:w-full data-[state=full]:border-l-0",
+ SHELF_TRANSITION_CLASS,
+ "data-[state=closed]:invisible data-[state=closed]:[transition:visibility_0s_linear_220ms]",
)}
>
{srLabel === undefined ? null : (
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
index 4ea2338fee..4f8fefc2ea 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.test.tsx
@@ -5,6 +5,7 @@ import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { CompactViewportOverrideProvider } from "@bb/shared-ui/hooks/use-compact-viewport";
import { dispatchBrowserViewBoundsSync } from "@/lib/browser-view-bounds-sync";
+import { setCompactSidebarDrawerShowing } from "@/components/ui/sidebar-mobile-drawer-visibility";
import {
PaneContext,
type PaneContextValue,
@@ -101,7 +102,9 @@ interface QueuedAnimationFrames {
interface RenderLayoutArgs {
collapseActive?: boolean;
+ compactPresentation?: "shelf" | "full";
isCompactViewport: boolean;
+ onClose?: () => void;
isFocusedHosted?: boolean;
open: boolean;
panelGroupKey?: string;
@@ -154,7 +157,7 @@ function renderLayout(args: RenderLayoutArgs) {
,
renderArgs.isFocusedHosted,
@@ -668,3 +672,55 @@ describe("SecondaryPanelLayout", () => {
expect(dispatchBrowserViewBoundsSync).not.toHaveBeenCalled();
});
});
+
+describe("compact sidebar and right panel", () => {
+ it("closes the right panel when the sidebar drawer opens so only one shelf is engaged", () => {
+ const onClose = vi.fn();
+ renderLayout({
+ isCompactViewport: true,
+ onClose,
+ open: true,
+ renderPanel: createPanelRenderer(),
+ resetKey: "thread-1",
+ });
+
+ expect(onClose).not.toHaveBeenCalled();
+
+ act(() => setCompactSidebarDrawerShowing(true));
+ expect(onClose).toHaveBeenCalledTimes(1);
+
+ act(() => setCompactSidebarDrawerShowing(false));
+ });
+
+ it("leaves a closed right panel alone when the sidebar drawer opens", () => {
+ const onClose = vi.fn();
+ renderLayout({
+ isCompactViewport: true,
+ onClose,
+ open: false,
+ renderPanel: createPanelRenderer(),
+ resetKey: "thread-1",
+ });
+
+ act(() => setCompactSidebarDrawerShowing(true));
+ expect(onClose).not.toHaveBeenCalled();
+
+ act(() => setCompactSidebarDrawerShowing(false));
+ });
+
+ it("keeps the desktop panel open when the sidebar drawer flag flips", () => {
+ const onClose = vi.fn();
+ renderLayout({
+ isCompactViewport: false,
+ onClose,
+ open: true,
+ renderPanel: createPanelRenderer(),
+ resetKey: "thread-1",
+ });
+
+ act(() => setCompactSidebarDrawerShowing(true));
+ expect(onClose).not.toHaveBeenCalled();
+
+ act(() => setCompactSidebarDrawerShowing(false));
+ });
+});
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
index 26dba8f058..bafad99c04 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelLayout.tsx
@@ -5,6 +5,7 @@ import {
useMemo,
useRef,
useState,
+ useSyncExternalStore,
type Key,
type ReactNode,
} from "react";
@@ -31,6 +32,10 @@ import {
usePanelCollapseTransitionsReady,
} from "./panelTransitionTokens";
import { secondaryPanelWidthPercentAtom } from "./threadSecondaryPanelAtoms";
+import {
+ isCompactSidebarDrawerShowing,
+ subscribeCompactSidebarDrawerShowing,
+} from "@/components/ui/sidebar-mobile-drawer-visibility";
const FULL_PANEL_SIZE_PERCENT = 100;
const MAIN_PANEL_MIN_SIZE_PERCENT = 30;
@@ -64,6 +69,7 @@ interface SecondaryPanelLayoutProps {
renderPanel: (args: SecondaryPanelRenderArgs) => ReactNode;
renderHostedPanel?: (panel: ReactNode) => ReactNode;
composerHost: PluginComposerHost | null;
+ compactPresentation: "shelf" | "full";
}
export function SecondaryPanelLayout({
@@ -82,10 +88,20 @@ export function SecondaryPanelLayout({
renderPanel,
renderHostedPanel,
composerHost,
+ compactPresentation,
}: SecondaryPanelLayoutProps) {
const paneContext = useOptionalPaneContext();
const secondaryPanelHost = paneContext?.secondaryPanelHost ?? null;
const renderAsDrawer = useIsCompactViewport();
+ const sidebarDrawerShowing = useSyncExternalStore(
+ subscribeCompactSidebarDrawerShowing,
+ isCompactSidebarDrawerShowing,
+ () => false,
+ );
+ useEffect(() => {
+ if (!renderAsDrawer || !open || !sidebarDrawerShowing) return;
+ onClose();
+ }, [onClose, open, renderAsDrawer, sidebarDrawerShowing]);
const transitionsReady = usePanelCollapseTransitionsReady(
resetKey,
!renderAsDrawer,
@@ -346,6 +362,7 @@ export function SecondaryPanelLayout({
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 a6819f940f..4442ef0c25 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.collapseControl.test.tsx
@@ -154,6 +154,49 @@ function renderFixedTabSplit({
}
describe("ThreadSecondaryPanel compact file content", () => {
+ it("renders the available tab while persisted active state catches up", () => {
+ const { wrapper: Wrapper } = createQueryClientTestHarness();
+ const fallbackTab = createWorkspaceFilePreviewFixedPanelTab({
+ environmentId: "env-test",
+ projectId: "project-test",
+ tab: {
+ lineRange: null,
+ path: "src/recovered.ts",
+ source: { kind: "working-tree" },
+ statusLabel: null,
+ },
+ });
+
+ render(
+
+
+ (
+ Recovered tab body
+ )),
+ ]}
+ isConversationCollapsed={false}
+ isOpen
+ metadataContent={null}
+ onClose={noop}
+ onCollapse={noop}
+ onTabReorder={noop}
+ onOpenNewTab={noop}
+ onPanelFocus={noop}
+ onToggleConversationCollapse={noop}
+ renderAsDrawer
+ />
+
+ ,
+ );
+
+ expect(screen.getByText("Recovered tab body")).toBeTruthy();
+ });
+
it("renders arbitrary fixed-tab content through the shared surface", () => {
const { wrapper: Wrapper } = createQueryClientTestHarness();
const fixedTab = createPluginPageFixedPanelTab({
diff --git a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
index 361d6e936f..e175867a0e 100644
--- a/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
+++ b/apps/app/src/components/secondary-panel/ThreadSecondaryPanel.stories.tsx
@@ -14,6 +14,7 @@ import {
import type { ThreadSecondaryPanel as ThreadSecondaryPanelTab } from "@/lib/thread-secondary-panel";
import { Icon } from "@bb/shared-ui/icon";
import { TooltipProvider } from "@bb/shared-ui/tooltip";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { SidebarProvider } from "@/components/ui/sidebar";
import {
createGitDiffFixedPanelTab,
@@ -42,6 +43,8 @@ import {
} from "./ThreadMetadataContent.fixtures";
import { resolveRightPanelFileVisual } from "./rightPanelFileVisuals";
import { useThreadStorageBrowser } from "./useThreadStorageBrowser";
+import { FilePreview } from "./FilePreview";
+import { threadListQueryKey } from "@/hooks/queries/query-keys";
export default {
title: "right-panel/Tabbed shell",
@@ -229,8 +232,27 @@ function RepresentativeInfoContent() {
},
onCommitClick: noop,
};
+ const [queryClient] = useState(() => {
+ const client = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: Infinity } },
+ });
+ client.setQueryData(
+ threadListQueryKey({
+ projectId: props.thread.projectId,
+ sourceThreadId: props.thread.id,
+ originKind: "fork",
+ archived: false,
+ }),
+ [],
+ );
+ return client;
+ });
- return ;
+ return (
+
+
+
+ );
}
interface ShellArgs {
@@ -274,14 +296,27 @@ function ShellRow({
);
}
-const representativeFileContent = (
-
-
ThreadSecondaryPanel.tsx
-
{`export function ThreadSecondaryPanel() {
+const REPRESENTATIVE_FILE_SOURCE = `export function ThreadSecondaryPanel() {
return …;
-}`}
-
-);
+}`;
+
+function RepresentativeFileContent({ path }: { path: string }) {
+ return (
+
+ );
+}
interface TerminalTabFixture {
terminalId: string;
@@ -384,7 +419,7 @@ function FileTabsShellInner({
statusLabel: null,
onSelect: () => setActiveFilename(filename),
onClose: () => handleCloseFile(filename),
- renderContent: () => representativeFileContent,
+ renderContent: () => ,
tab,
};
}),
@@ -599,7 +634,7 @@ function ProductionSplitPanesStory() {
tab.kind === "terminal" ? (
) : (
- representativeFileContent
+
),
tab,
})),
@@ -652,12 +687,12 @@ export function SplitPanes() {
);
}
-export function CompactShelfTabs() {
+export function PhoneWidthTabs() {
return (
tab.tab.id === activeTab?.id);
const visibleTabs = useMemo(
() => tabs.filter((tab) => tab.isHidden !== true),
[tabs],
);
+ const activeRenderableTab =
+ tabs.find((tab) => tab.tab.id === activeTab?.id) ??
+ (activeTab === null && fixedTabs.length === 0
+ ? visibleTabs[0]
+ : undefined);
const hasActiveRenderableTab = activeRenderableTab !== undefined;
const hidePanelIconName = RIGHT_PANEL_TOGGLE_ICON_NAME;
const conversationCollapseControl =
@@ -961,7 +965,7 @@ export function ThreadSecondaryPanel({
) : (
renderPanelSurface({
activeSurfaceFixedTab: activeFixedTab,
- activeSurfaceTabId: activeTab?.id ?? null,
+ activeSurfaceTabId: activeRenderableTab?.tab.id ?? activeTab?.id ?? null,
surfaceTabs: tabs,
fixedSurfaceTabs: fixedTabs,
isFocused: true,
diff --git a/apps/app/src/components/secondary-panel/panelToggleControlState.test.ts b/apps/app/src/components/secondary-panel/panelToggleControlState.test.ts
index a65b456bb9..47521ec335 100644
--- a/apps/app/src/components/secondary-panel/panelToggleControlState.test.ts
+++ b/apps/app/src/components/secondary-panel/panelToggleControlState.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it, vi } from "vitest";
-import { resolveConversationCollapseControl } from "./panelToggleControlState";
+import {
+ getCompactPanelPresentation,
+ resolveConversationCollapseControl,
+} from "./panelToggleControlState";
describe("resolveConversationCollapseControl", () => {
it("collapses the conversation when it is shown", () => {
@@ -34,3 +37,31 @@ describe("resolveConversationCollapseControl", () => {
expect(onToggleConversationCollapse).toHaveBeenCalledTimes(1);
});
});
+
+describe("getCompactPanelPresentation", () => {
+ it("keeps thread info in the shelf so the thread stays visible beside it", () => {
+ expect(getCompactPanelPresentation("thread-info")).toBe("shelf");
+ });
+
+ it("falls back to the shelf when no tab is active yet", () => {
+ expect(getCompactPanelPresentation(undefined)).toBe("shelf");
+ });
+
+ it("uses the rendered fallback tab while active state catches up", () => {
+ expect(getCompactPanelPresentation(undefined, "terminal")).toBe("full");
+ });
+
+ it.each([
+ "git-diff",
+ "terminal",
+ "host-file-preview",
+ "workspace-file-preview",
+ "thread-storage-file-preview",
+ "browser",
+ "new-tab",
+ "plugin-page-fixed",
+ "plugin-panel",
+ ])("gives %s the full page, where its content is usable", (kind) => {
+ expect(getCompactPanelPresentation(kind)).toBe("full");
+ });
+});
diff --git a/apps/app/src/components/secondary-panel/panelToggleControlState.ts b/apps/app/src/components/secondary-panel/panelToggleControlState.ts
index 1e03375187..a52ef79699 100644
--- a/apps/app/src/components/secondary-panel/panelToggleControlState.ts
+++ b/apps/app/src/components/secondary-panel/panelToggleControlState.ts
@@ -49,3 +49,13 @@ export function resolveConversationCollapseControl({
}
export const RIGHT_PANEL_TOGGLE_ICON_NAME = "PanelRight";
+
+export function getCompactPanelPresentation(
+ activeTabKind: string | undefined,
+ fallbackTabKind?: string,
+): "shelf" | "full" {
+ const resolvedTabKind = activeTabKind ?? fallbackTabKind;
+ return resolvedTabKind === undefined || resolvedTabKind === "thread-info"
+ ? "shelf"
+ : "full";
+}
diff --git a/apps/app/src/components/ui/app-overlay-layers.ts b/apps/app/src/components/ui/app-overlay-layers.ts
index 297b6ca41e..90e8e4e9d6 100644
--- a/apps/app/src/components/ui/app-overlay-layers.ts
+++ b/apps/app/src/components/ui/app-overlay-layers.ts
@@ -1,5 +1,7 @@
export const APP_OVERLAY_LAYER = {
secondaryPanel: 0,
secondaryPanelDismiss: 40,
- sidebarTrigger: 50,
+ sidebarTrigger: 44,
+ secondaryPanelFullPage: 45,
+ sharedPortaledOverlay: 50,
} as const;
diff --git a/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts b/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts
index e440cec642..b461feacc5 100644
--- a/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts
+++ b/apps/app/src/components/ui/secondary-panel-shelf-visibility.ts
@@ -1,15 +1,20 @@
-let compactSecondaryPanelShelfShowing = false;
+export type CompactSecondaryPanelPresentation = "closed" | "shelf" | "full";
+
+let compactSecondaryPanelPresentation: CompactSecondaryPanelPresentation =
+ "closed";
const listeners = new Set<() => void>();
-export function isCompactSecondaryPanelShelfShowing(): boolean {
- return compactSecondaryPanelShelfShowing;
+export function getCompactSecondaryPanelPresentation(): CompactSecondaryPanelPresentation {
+ return compactSecondaryPanelPresentation;
}
-export function setCompactSecondaryPanelShelfShowing(showing: boolean): void {
- if (compactSecondaryPanelShelfShowing === showing) {
+export function setCompactSecondaryPanelPresentation(
+ presentation: CompactSecondaryPanelPresentation,
+): void {
+ if (compactSecondaryPanelPresentation === presentation) {
return;
}
- compactSecondaryPanelShelfShowing = showing;
+ compactSecondaryPanelPresentation = presentation;
for (const listener of listeners) {
listener();
}
diff --git a/apps/app/src/components/ui/sidebar.test.tsx b/apps/app/src/components/ui/sidebar.test.tsx
index 4f615fa90d..39439df854 100644
--- a/apps/app/src/components/ui/sidebar.test.tsx
+++ b/apps/app/src/components/ui/sidebar.test.tsx
@@ -435,17 +435,17 @@ describe("mobile sidebar shelf stacking", () => {
expect(inset.className).not.toContain(
"data-[sidebar-shelf=open]:rounded",
);
- expect(inset.className).not.toContain("data-[panel-shelf=open]:rounded");
+ expect(inset.className).not.toContain("data-[panel-shelf=shelf]:rounded");
expect(inset.className).not.toContain(
"data-[sidebar-shelf=open]:overflow-hidden",
);
expect(inset.className).not.toContain(
- "data-[panel-shelf=open]:overflow-hidden",
+ "data-[panel-shelf=shelf]:overflow-hidden",
);
expect(inset.className).not.toContain(
"data-[sidebar-shelf=open]:shadow",
);
- expect(inset.className).not.toContain("data-[panel-shelf=open]:shadow");
+ expect(inset.className).not.toContain("data-[panel-shelf=shelf]:shadow");
});
it("leaves the page untouched by the shelf on desktop", () => {
diff --git a/apps/app/src/components/ui/sidebar.tsx b/apps/app/src/components/ui/sidebar.tsx
index 39e4187601..445f23788c 100644
--- a/apps/app/src/components/ui/sidebar.tsx
+++ b/apps/app/src/components/ui/sidebar.tsx
@@ -16,7 +16,7 @@ import {
} from "@bb/shared-ui/tooltip";
import { setCompactSidebarDrawerShowing } from "./sidebar-mobile-drawer-visibility.js";
import {
- isCompactSecondaryPanelShelfShowing,
+ getCompactSecondaryPanelPresentation,
subscribeCompactSecondaryPanelShelfShowing,
} from "./secondary-panel-shelf-visibility.js";
@@ -1887,10 +1887,10 @@ const SidebarInset = React.forwardRef<
}
}, [clearSwipeSession, isCompactViewport, openMobile]);
- const secondaryPanelShelfShowing = React.useSyncExternalStore(
+ const secondaryPanelPresentation = React.useSyncExternalStore(
subscribeCompactSecondaryPanelShelfShowing,
- isCompactSecondaryPanelShelfShowing,
- () => false,
+ getCompactSecondaryPanelPresentation,
+ () => "closed" as const,
);
const shelfState = isCompactViewport
? openMobile
@@ -1899,9 +1899,7 @@ const SidebarInset = React.forwardRef<
: undefined;
const panelShelfState =
isCompactViewport && !openMobile
- ? secondaryPanelShelfShowing
- ? "open"
- : "closed"
+ ? secondaryPanelPresentation
: undefined;
return (
@@ -1919,7 +1917,8 @@ const SidebarInset = React.forwardRef<
"group/page-inset relative flex h-full min-h-0 min-w-0 flex-1 flex-col bg-background max-md:z-30",
SIDEBAR_MOBILE_SHELF_INSET_TRANSITION_CLASS,
"data-[sidebar-shelf=open]:translate-x-(--sidebar-width-mobile) data-[sidebar-shelf]:will-change-[translate]",
- "data-[panel-shelf=open]:-translate-x-(--secondary-panel-width-mobile) data-[panel-shelf]:will-change-[translate]",
+ "data-[panel-shelf=shelf]:-translate-x-(--secondary-panel-width-mobile) data-[panel-shelf]:will-change-[translate]",
+ "data-[panel-shelf=full]:-translate-x-full",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
className,
)}
diff --git a/apps/app/src/views/RootComposeSecondaryContent.tsx b/apps/app/src/views/RootComposeSecondaryContent.tsx
index cf4521d74d..d6af8f8f56 100644
--- a/apps/app/src/views/RootComposeSecondaryContent.tsx
+++ b/apps/app/src/views/RootComposeSecondaryContent.tsx
@@ -17,6 +17,7 @@ import {
} from "@/lib/bb-desktop";
import { RootComposeCompactHome } from "./RootComposeCompactHome";
import { useOptionalPaneContext } from "./thread-detail/PaneContext";
+import { getCompactPanelPresentation } from "@/components/secondary-panel/panelToggleControlState";
const ROOT_COMPOSE_MAX_WIDTH_CLASS = "max-w-[760px]";
@@ -149,6 +150,13 @@ export function RootComposeSecondaryContent({
mainPanelId="root-compose-main-panel"
main={mainContent}
composerHost={composerHost}
+ compactPresentation={getCompactPanelPresentation(
+ threadSecondaryPanelProps.activeTab?.kind,
+ threadSecondaryPanelProps.fixedTabs[0]?.tab.kind ??
+ threadSecondaryPanelProps.tabs.find(
+ (tab) => tab.isHidden !== true,
+ )?.tab.kind,
+ )}
renderPanel={({
presentation,
canShowNativeBrowserView,
diff --git a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
index 0ccf1a23f1..91bfbb000b 100644
--- a/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
+++ b/apps/app/src/views/thread-detail/ThreadDetailSecondaryContent.tsx
@@ -16,6 +16,7 @@ import {
import { DETAIL_GRID_CLASS } from "@/components/ui/detail-card.js";
import { useThreads } from "@/hooks/queries/thread-queries";
import { ThreadTimelinePane } from "./ThreadTimelinePane";
+import { getCompactPanelPresentation } from "@/components/secondary-panel/panelToggleControlState";
type ThreadTimelinePaneProps = Omit<
ComponentProps,
@@ -130,6 +131,13 @@ function ThreadDetailSecondaryContentBody({
onToggle: onToggleConversationCollapse,
}}
composerHost={composerHost}
+ compactPresentation={getCompactPanelPresentation(
+ threadSecondaryPanelProps.activeTab?.kind,
+ threadSecondaryPanelProps.fixedTabs[0]?.tab.kind ??
+ threadSecondaryPanelProps.tabs.find(
+ (tab) => tab.isHidden !== true,
+ )?.tab.kind,
+ )}
renderHostedPanel={renderHostedPanel}
renderPanel={({
presentation,