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({