diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelHostLayoutContext.ts b/apps/app/src/components/secondary-panel/SecondaryPanelHostLayoutContext.ts index 01e637b1f..f923f1d80 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelHostLayoutContext.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelHostLayoutContext.ts @@ -20,6 +20,14 @@ export interface SecondaryPanelHostLayout { isOpen: boolean; /** The panel remains logically open but is hidden while a thread is full screen. */ isSuppressed: boolean; + /** + * Whether the host paints its toggle over the workspace's top-right corner. + * An open panel hosts the toggle in its own chrome, and a full-screen pane + * hides it, so the right-edge pane headers reserve that corner only while + * this is true. The empty-state panel has no chrome of its own, so the + * corner toggle stays with it — otherwise no button could close it. + */ + pinsCornerToggle: boolean; } export const SecondaryPanelHostLayoutContext = diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx index 4a0fdae99..81b6136ca 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, @@ -52,6 +53,13 @@ const panelFullScreenState = vi.hoisted(() => ({ isMainCollapsed: false, })); const panelGroupLayoutState = vi.hoisted(() => ({ layout: [100, 0] })); +const panelCallbacks = vi.hoisted( + () => + new Map< + string, + { onCollapse?: () => void; onResize?: (size: number) => void } + >(), +); const commandHandlers = vi.hoisted(() => new Map boolean>()); interface ShortcutPresentationFixture { ariaKeyshortcuts: string; @@ -151,9 +159,26 @@ vi.mock("react-resizable-panels", async () => { return
{children}
; }); PanelGroup.displayName = "MockPanelGroup"; - const Panel = ({ children }: { children?: ReactNode }) => ( -
{children}
- ); + // Record each panel's lifecycle callbacks so a test can fire the ones the + // real library fires on its own, such as the initial-layout collapse. + const Panel = ({ + children, + id, + onCollapse, + onResize, + }: { + children?: ReactNode; + id?: string; + onCollapse?: () => void; + onResize?: (size: number) => void; + }) => { + if (id !== undefined) panelCallbacks.set(id, { onCollapse, onResize }); + return ( +
+ {children} +
+ ); + }; const PanelResizeHandle = ({ className, id, @@ -524,6 +549,7 @@ beforeEach(() => { afterEach(() => { cleanup(); threadStore.clear(); + panelCallbacks.clear(); resetPluginSlotStoreForTest(); delete window.bbDesktop; window.localStorage.clear(); @@ -1161,7 +1187,8 @@ describe("SplitThreadArea", () => { expect(toggle.classList).toContain("absolute"); expect(toggle.classList).toContain("hidden"); expect(toggle.classList).not.toContain("relative"); - expect(toggle.classList).toContain("right-2.5"); + // The corner button shares the pane header's px-4 action axis. + expect(toggle.classList).toContain("right-4"); expect(toggle.classList).toContain("top-2.5"); const hint = screen.getByText("Ctrl Shift P"); expect(hint.classList).toContain("absolute"); @@ -1260,7 +1287,7 @@ describe("SplitThreadArea", () => { ).toBe("true"); }); - it("omits app panel and full-screen controls from plugin panes", async () => { + it("shows the empty panel state while a plugin pane is focused", async () => { const layout = pluginSplitLayout(); layout.focusedPaneId = "pane-1"; renderSplitArea({ @@ -1281,36 +1308,93 @@ describe("SplitThreadArea", () => { throw new Error("Expected plugin split pane"); } - // Focusing the plugin pane hides the app panel without layering disabled - // app controls over the plugin's own header and right panel. + // The plugin pane publishes no panel, so the window panel keeps its place + // and states that plainly. Its toggle and pane controls all stay live. fireEvent.pointerDown(pluginPane); - await waitFor(() => - expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(), + const emptyState = await screen.findByTestId( + "split-workspace-empty-panel-state", ); - expect( - document.getElementById("split-workspace-empty-secondary-panel"), - ).toBeNull(); - expect( - document.getElementById("split-workspace-empty-secondary-panel-handle"), - ).toBeNull(); + expect(emptyState.textContent).toContain("This pane has no right panel."); + const pluginToggle = screen + .getByTestId("split-workspace-panel-toggle") + .querySelector("button"); + expect(pluginToggle?.hasAttribute("disabled")).toBe(false); expect( pluginPane.querySelector('button[aria-label*="Full Screen"]'), - ).toBeNull(); - expect( - screen.queryByTestId("split-workspace-empty-panel-state"), - ).toBeNull(); + ).not.toBeNull(); + + // The toggle closes and reopens the empty state on its own. + fireEvent.click(pluginToggle!); + await waitFor(() => + expect( + screen + .getByTestId("split-workspace-panel-toggle") + .querySelector("button") + ?.getAttribute("aria-expanded"), + ).toBe("false"), + ); + // Refocusing the thread pane restores the remembered open panel. fireEvent.pointerDown(screen.getByTestId("pane-thr-a")); - const restoredOpenToggle = await screen.findByRole("button", { - name: "Hide right panel", - }); - expect(restoredOpenToggle.getAttribute("aria-expanded")).toBe("true"); expect(screen.getByTestId("hosted-panel-thr-a")).toBeTruthy(); expect( screen.queryByTestId("split-workspace-empty-panel-state"), ).toBeNull(); }); + it("ignores the empty panel's initial collapse so a thread keeps its open panel", async () => { + const layout = pluginSplitLayout(); + layout.focusedPaneId = "pane-2"; + renderSplitArea({ + path: "/plugins/docs/docs", + layout, + routeAwareContent: true, + }); + + await screen.findByTestId("split-workspace-empty-panel-state"); + // react-resizable-panels reports the zero-width first layout as a + // collapse. Honoring it would harden the "adopt the first publisher's + // state" sentinel into closed before any pane published. + act(() => { + panelCallbacks + .get("split-workspace-empty-secondary-panel") + ?.onCollapse?.(); + }); + + fireEvent.pointerDown(screen.getByTestId("pane-thr-a")); + expect(await screen.findByTestId("hosted-panel-thr-a")).toBeTruthy(); + expect( + screen.getByRole("button", { name: "Hide right panel" }), + ).toBeTruthy(); + }); + + it("drops the corner reserve while the open empty panel holds the toggle", async () => { + const layout = pluginSplitLayout(); + layout.focusedPaneId = "pane-2"; + renderSplitArea({ + path: "/plugins/docs/docs", + layout, + routeAwareContent: true, + }); + + // pane-2 is the plugin pane at the right edge. Closed, the toggle sits on + // its header row, so the header keeps that corner free. + await screen.findByTestId("split-workspace-empty-panel-state"); + const close = screen.getByRole("button", { name: "Close pane" }); + expect(close.nextElementSibling?.tagName).toBe("SPAN"); + + // Open, the toggle moves over the panel and the header reclaims the slot. + fireEvent.click(screen.getByRole("button", { name: "Show right panel" })); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Close pane" }).nextElementSibling, + ).toBeNull(), + ); + expect( + screen.getByTestId("split-workspace-panel-toggle").classList, + ).not.toContain("hidden"); + }); + it("preserves plugin-owned right panels with and without a plugin split", async () => { setPluginSlotRegistrations("test-plugin", { homepageSections: [], @@ -1367,8 +1451,14 @@ describe("SplitThreadArea", () => { expect( screen.getByRole("button", { name: "Collapse notes sidebar" }), ).toBeTruthy(); - expect(screen.queryByTestId("split-workspace-panel-toggle")).toBeNull(); - expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); + // Neither plugin pane publishes a panel, so the window offers its empty + // state instead of dropping the control. + expect(screen.getByTestId("split-workspace-panel-toggle")).toBeTruthy(); + // The app panel belongs to a publishing pane, but full screen is pane + // chrome: every pane in a split owns it, plugin panes included. + expect(screen.getAllByRole("button", { name: /Full Screen/ })).toHaveLength( + 2, + ); fireEvent.click(screen.getAllByRole("button", { name: "Close pane" })[0]!); @@ -1376,9 +1466,7 @@ describe("SplitThreadArea", () => { expect(screen.queryByText("Automations content")).toBeNull(), ); expect( - screen - .getByText("Docs content with notes sidebar") - .closest(".isolate"), + screen.getByText("Docs content with notes sidebar").closest(".isolate"), ).toBeNull(); expect( screen.getByRole("button", { name: "Collapse notes sidebar" }), @@ -1644,7 +1732,9 @@ describe("SplitThreadArea", () => { expect((await contentRow(path))?.className).not.toContain("pl-[104px]"); } - expect(screen.queryByRole("button", { name: /Full Screen/ })).toBeNull(); + expect(screen.getAllByRole("button", { name: /Full Screen/ })).toHaveLength( + 4, + ); }); it("assigns exactly one top-left owner through eight-pane structural changes", async () => { @@ -1729,6 +1819,87 @@ describe("SplitThreadArea", () => { ).not.toBe(0); }); + // The host pins its panel toggle over the workspace corner. A plugin pane in + // that corner used to skip the reserve, so the toggle covered Close pane. + it("reserves the window toggle corner for a plugin pane at the top right", async () => { + setPluginSlotRegistrations("docs", { + homepageSections: [], + settingsSections: [], + navPanels: [ + { + id: "docs", + title: "Docs", + icon: "FileText", + path: "docs", + component: () =>
Docs panel
, + }, + ], + threadPanelActions: [], + pendingInteractions: [], + sidebarFooterActions: [], + fileOpeners: [], + messageDirectives: [], + }); + + renderSplitArea({ + path: "/", + layout: { + root: { + type: "split", + dir: "col", + sizes: [0.5, 0.5], + children: [ + { type: "pane", paneId: "pane-docs", content: docsContent }, + { type: "pane", paneId: "pane-new", content: newThreadContent }, + ], + }, + focusedPaneId: "pane-new", + }, + routeContent: newThreadContent, + }); + + await screen.findByText("Docs panel"); + expect(screen.getByTestId("split-workspace-panel-toggle")).toBeTruthy(); + const [pluginClose] = screen.getAllByRole("button", { name: "Close pane" }); + const reserve = pluginClose?.nextElementSibling; + expect(reserve?.tagName).toBe("SPAN"); + expect(reserve?.getAttribute("aria-hidden")).toBe("true"); + + // Full screen hides the host toggle, so the reserved slot must go with it. + fireEvent.click(screen.getAllByRole("button", { name: /Full Screen/ })[0]!); + await waitFor(() => + expect( + screen.getAllByRole("button", { name: "Close pane" })[0] + ?.nextElementSibling, + ).toBeNull(), + ); + }); + + it("drops the corner reserve once an open panel hosts the toggle", async () => { + const layout = pluginSplitLayout(); + layout.focusedPaneId = "pane-1"; + renderSplitArea({ + path: threadPath("thr-a"), + layout, + routeAwareContent: true, + }); + + // pane-2 is the plugin pane at the right edge; the thread pane's panel + // starts open, so its own chrome carries the toggle. + const pluginClose = await screen.findByRole("button", { + name: "Close pane", + }); + expect(pluginClose.nextElementSibling).toBeNull(); + + fireEvent.click(screen.getByRole("button", { name: "Hide right panel" })); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Close pane" }).nextElementSibling + ?.tagName, + ).toBe("SPAN"), + ); + }); + it("uses automation breadcrumbs in the split-owned plugin header", async () => { setPluginSlotRegistrations("automations", { homepageSections: [], diff --git a/apps/app/src/views/thread-detail/SplitThreadArea.tsx b/apps/app/src/views/thread-detail/SplitThreadArea.tsx index d12ddc307..1e923e668 100644 --- a/apps/app/src/views/thread-detail/SplitThreadArea.tsx +++ b/apps/app/src/views/thread-detail/SplitThreadArea.tsx @@ -274,8 +274,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { layout !== null && countPanes(layout.root) > 1 && maximizedPaneId !== null && - maximizedPane !== null && - maximizedPane.content.kind !== "plugin-panel" + maximizedPane !== null ? maximizedPaneId : null; const { @@ -333,8 +332,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { if ( layout === null || countPanes(layout.root) < 2 || - maximizedPane === null || - maximizedPane.content.kind === "plugin-panel" + maximizedPane === null ) { setMaximizedPaneId(null); return; @@ -404,12 +402,7 @@ function SplitThreadAreaContent({ routeContent }: SplitThreadAreaProps) { (paneId: string) => { const current = store.get(splitLayoutAtom); const pane = current === null ? null : findPane(current.root, paneId); - if ( - current === null || - countPanes(current.root) < 2 || - pane === null || - pane.content.kind === "plugin-panel" - ) { + if (current === null || countPanes(current.root) < 2 || pane === null) { return; } if (current.focusedPaneId !== paneId) { @@ -791,22 +784,14 @@ function SplitTree(props: SplitTreeProps) { isFocused={isFocused} isSplitPane secondaryPanelRegistry={props.secondaryPanelRegistry} - reservesWindowPanelToggle={ - node.content.kind !== "plugin-panel" && - (isMaximized || (isTopRow && isRightEdge)) - } + // Position alone decides this: the host pins its toggle over the + // workspace corner, so a plugin pane sitting there must reserve the + // same footprint or the toggle lands on its Close pane button. + reservesWindowPanelToggle={isMaximized || (isTopRow && isRightEdge)} onRequestClose={() => props.onClosePane(node.paneId)} isMaximized={isMaximized} - onToggleMaximize={ - node.content.kind === "plugin-panel" - ? null - : () => props.onToggleMaximizePane(node.paneId) - } - onMoveToSide={ - node.content.kind === "plugin-panel" - ? undefined - : (side) => props.onMovePaneToSide(node.paneId, side) - } + onToggleMaximize={() => props.onToggleMaximizePane(node.paneId)} + onMoveToSide={(side) => props.onMovePaneToSide(node.paneId, side)} isBoundedPane isTopRow={isMaximized || isTopRow} ownsWindowTopLeft={ @@ -1033,8 +1018,9 @@ function NonThreadPaneContent({ reservesWindowPanelToggle: false, isFocused: true, }; - const isWindowPanelOpen = - useContext(SecondaryPanelHostLayoutContext)?.isOpen === true; + const hostLayout = useContext(SecondaryPanelHostLayoutContext); + // The corner belongs to the pane unless the host paints its toggle there. + const showsWindowPanelToggle = hostLayout?.pinsCornerToggle === true; const [desktopInfo] = useState(getBbDesktopInfo); const usesDesktopChrome = shouldUseMacosDesktopChrome(desktopInfo); const panel = @@ -1070,7 +1056,7 @@ function NonThreadPaneContent({ subPath={content.kind === "plugin-panel" ? content.subPath : ""} /> ) : null} - {content.kind === "plugin-panel" ? null : } + {onRequestClose ? ( ) : null} - {reservesWindowPanelToggle && !isWindowPanelOpen ? ( + {reservesWindowPanelToggle && showsWindowPanelToggle ? ( // The host's shortcut hint drops below the chrome row; reserve only - // its stable 28px corner button beside these pane actions. With the - // window panel open, the toggle overlays the panel's own chrome - // instead, so the pane actions sit flush at the pane edge. + // its stable 28px corner button beside these pane actions. Whenever + // the host hides that toggle, the pane actions sit flush at the pane + // edge instead of trailing an empty slot. ) : null} diff --git a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx index e3e75a7b7..8df7c19d3 100644 --- a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx +++ b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx @@ -6,13 +6,15 @@ import { useState, type ReactNode, } from "react"; -import { useAtomValue } from "jotai"; +import { useAtomValue, useSetAtom } from "jotai"; import { Panel, PanelGroup, + PanelResizeHandle, type ImperativePanelGroupHandle, } from "react-resizable-panels"; import { Button } from "@bb/shared-ui/button"; +import { EmptyStatePanel } from "@bb/shared-ui/empty-state"; import { Icon } from "@bb/shared-ui/icon"; import { cn } from "@bb/shared-ui/lib/utils"; import { HEADER_ICON_BUTTON_CLASS } from "@/components/layout/AppPageHeader"; @@ -22,12 +24,17 @@ import { useAppCommandShortcut, } from "@/components/commands/AppCommandProvider"; import { secondaryPanelWidthPercentAtom } from "@/components/secondary-panel/threadSecondaryPanelAtoms"; +import { + THREAD_SECONDARY_PANEL_MAX_SIZE_PERCENT, + THREAD_SECONDARY_PANEL_MIN_SIZE_PERCENT, +} from "@/components/secondary-panel/ThreadSecondaryPanel"; import { SecondaryPanelHostLayoutContext, type SecondaryPanelHostLayout, } from "@/components/secondary-panel/SecondaryPanelHostLayoutContext"; import { PANEL_COLLAPSE_TRANSITION_CLASS, + PANEL_RESIZE_HIT_AREA_MARGINS, } from "@/components/secondary-panel/panelTransitionTokens"; import { MACOS_APP_REGION_NO_DRAG_CLASS } from "@/lib/bb-desktop"; import { PluginComposerHostProvider } from "@/components/plugin/plugin-composer-host"; @@ -62,7 +69,7 @@ export function SplitWorkspaceSecondaryPanelHost({ // Null until the first pane publishes, so entering a split adopts the // focused content's persisted state. const [isPanelVisible, setIsPanelVisible] = useState(null); - const isOpen = model !== null && (isPanelVisible ?? model.isOpen); + const isOpen = isPanelVisible ?? model?.isOpen ?? false; const lastTargetRef = useRef<{ paneId: string; contentKey: string; @@ -141,36 +148,55 @@ export function SplitWorkspaceSecondaryPanelHost({ panelWidthPercent, ]); - // The remembered window visibility is intentionally left unchanged while a - // plugin pane is focused, so returning to a thread restores its app panel. + // A pane without a panel keeps the control working: the toggle drives the + // window visibility directly, so the empty state opens and closes like any + // panel. Refocusing a publishing pane re-aligns through the effect above. const toggleWindowPanel = () => { - model?.onToggle(); + if (model !== null) { + model.onToggle(); + return; + } + setIsPanelVisible((current) => !(current ?? false)); }; useAppCommandHandler("panel.toggle", () => { - return model === null; + if (model !== null) return false; + toggleWindowPanel(); + return true; }); - const toggleLabel = - model === null - ? "Right panel unavailable" - : isOpen - ? "Hide right panel" - : "Show right panel"; + // Resizing the empty-state panel mirrors the real panel's persistence: the + // shared width atom updates on drag end, so the size carries to whichever + // pane's panel shows next. Dragging it collapsed closes the window panel. + const setPanelWidthPercent = useSetAtom(secondaryPanelWidthPercentAtom); + const lastEmptyPanelSizeRef = useRef(0); + const handleEmptyPanelResize = (size: number) => { + if (size > 0) lastEmptyPanelSizeRef.current = size; + }; + const handleEmptyPanelDragging = (isDragging: boolean) => { + if (isDragging || lastEmptyPanelSizeRef.current <= 0) return; + setPanelWidthPercent(lastEmptyPanelSizeRef.current); + }; + const handleEmptyPanelCollapse = () => { + // A panel that mounts at zero width reports that first layout as a + // collapse. Honoring it would turn the "adopt the first publisher's state" + // sentinel into a hard closed, and the next thread would lose its + // persisted-open panel. Only a collapse after a real width is the user's. + if (lastEmptyPanelSizeRef.current <= 0) return; + setIsPanelVisible(false); + }; + + const toggleLabel = isOpen ? "Hide right panel" : "Show right panel"; + // An open pane panel carries the toggle in its own chrome, and a full-screen + // pane hides it. The empty state has no chrome, so it keeps the button. + const showsCornerToggle = !isPaneMaximized && !(isOpen && model !== null); + // The button only lands on a pane header while the panel is closed. Once any + // panel opens, it sits over that panel, so no pane header reserves for it. + const pinsCornerToggle = showsCornerToggle && !isOpen; const hostLayout = useMemo( - () => ({ isOpen, isSuppressed: isPaneMaximized }), - [isOpen, isPaneMaximized], + () => ({ isOpen, isSuppressed: isPaneMaximized, pinsCornerToggle }), + [isOpen, isPaneMaximized, pinsCornerToggle], ); - if (model === null) { - return ( - -
- {children} -
-
- ); - } - return ( // The layout context serves two consumers: a freshly mounted pane panel // sizes its resizable Panel to the window visibility (its own persisted @@ -183,8 +209,11 @@ export function SplitWorkspaceSecondaryPanelHost({
- - {model.panel} - + {model === null ? ( + <> + {/* Working twin of the panel's gutter-style resize handle: the + seam keeps the split dividers' body, and the window panel + stays resizable even while it shows the empty state. */} + + +
+ + This pane has no right panel. + +
+
+ + ) : ( + + {model.panel} + + )}
diff --git a/official-plugins/docs/app.test.tsx b/official-plugins/docs/app.test.tsx index 3e7d7e69a..0fba589b8 100644 --- a/official-plugins/docs/app.test.tsx +++ b/official-plugins/docs/app.test.tsx @@ -123,19 +123,16 @@ describe("Docs nav panel", () => { const HeaderContent = panel.headerContent!; const header = render(); const headerSegment = header.getByTestId("notes-sidebar-header"); - const headerBackground = header.getByTestId( - "notes-sidebar-header-background", - ); expect(headerSegment.classList.contains("w-8")).toBe(true); - expect(headerBackground.classList.contains("bg-sidebar")).toBe(true); - expect(headerBackground.style.width).toBe("288px"); - expect(headerBackground.style.right).toBe("-16px"); + // The sidebar is the plugin's own panel: it stays below the host header, + // so no plugin chrome may paint over the header's seam. + expect(header.queryByTestId("notes-sidebar-header-background")).toBeNull(); const toolbar = slot.getByRole("toolbar", { name: "Notes sidebar actions", }); expect(slot.getByRole("navigation", { name: "Notes" })).toBeTruthy(); expect( - slot.container.querySelector("aside")?.classList.contains("bg-sidebar"), + slot.container.querySelector("aside")?.classList.contains("bg-muted/20"), ).toBe(true); expect( within(toolbar).getByRole("button", { name: "Search notes" }), @@ -155,12 +152,10 @@ describe("Docs nav panel", () => { header.getByRole("button", { name: "Collapse notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("0px"); - expect(headerBackground.style.width).toBe("48px"); fireEvent.click( header.getByRole("button", { name: "Expand notes sidebar" }), ); expect(slot.container.querySelector("aside")?.style.width).toBe("288px"); - expect(headerBackground.style.width).toBe("288px"); header.unmount(); const fallbackToggle = await slot.findByRole("button", { @@ -175,7 +170,7 @@ describe("Docs nav panel", () => { ); }); - it("keeps the sidebar header background aligned behind split host controls", () => { + it("keeps the header toggle inside its own box in a split pane", () => { const HeaderContent = app.navPanels[0]!.headerContent!; const header = render(
@@ -184,11 +179,10 @@ describe("Docs nav panel", () => { ); const headerSegment = header.getByTestId("notes-sidebar-header"); - const background = header.getByTestId("notes-sidebar-header-background"); + // The toggle must not reach into the host's own split-pane controls. expect(headerSegment.classList.contains("-mr-4")).toBe(false); expect(headerSegment.classList.contains("w-8")).toBe(true); - expect(background.style.right).toBe("-48px"); - expect(background.style.width).toBe("288px"); + expect(header.queryByTestId("notes-sidebar-header-background")).toBeNull(); }); it("keeps the right sidebar pinned while a note loads", async () => { @@ -364,16 +358,6 @@ describe("Docs nav panel", () => { expect(firstAside?.style.width).toBe("400px"); expect(secondAside?.style.width).toBe("288px"); - expect( - within(firstHeader.container).getByTestId( - "notes-sidebar-header-background", - ).style.width, - ).toBe("400px"); - expect( - within(secondHeader.container).getByTestId( - "notes-sidebar-header-background", - ).style.width, - ).toBe("288px"); fireEvent.click( within(firstHeader.container).getByRole("button", { diff --git a/official-plugins/docs/app.tsx b/official-plugins/docs/app.tsx index c49dc7f28..b02238751 100644 --- a/official-plugins/docs/app.tsx +++ b/official-plugins/docs/app.tsx @@ -1241,11 +1241,6 @@ function orderEntries( } const SIDEBAR_AUTO_COLLAPSE_PANE_WIDTH = 640; -const HEADER_STANDALONE_EDGE_OFFSET = 16; -// Split host actions follow plugin header content: 4px action gap + 28px close -// button + the header's 16px edge padding. The visual sidebar background can -// extend behind that host-owned chrome, but its layout box must not overlap it. -const HEADER_SPLIT_EDGE_OFFSET = 48; interface NotesSidebarState { headerMounted: boolean; @@ -1261,6 +1256,11 @@ interface NotesSidebarStore { listeners: Set<() => void>; } +// The header toggle and the sidebar view live in separate React subtrees and +// find each other only through this map. Entries stay for the session: a +// deleted entry lets one subtree keep the old store while the other creates a +// new one, which silently breaks the header toggle. A reopened pane starts +// clean anyway, because the view resets the state on its first mount. const notesSidebarStores = new Map(); const STANDALONE_SIDEBAR_SCOPE = "standalone"; @@ -1270,7 +1270,6 @@ function notesSidebarVaultKey(subPath: string): string { } function useNotesSidebarScope(subPath: string): { - isSplitPane: boolean; scopeRef(element: HTMLElement | null): void; storeKey: string; } { @@ -1286,7 +1285,6 @@ function useNotesSidebarScope(subPath: string): { ); }, []); return { - isSplitPane: paneScope !== STANDALONE_SIDEBAR_SCOPE, scopeRef, storeKey: `${paneScope}:${notesSidebarVaultKey(subPath)}`, }; @@ -1327,19 +1325,6 @@ function updateNotesSidebarState( for (const listener of store.listeners) listener(); } -function deleteUnusedNotesSidebarStore( - key: string, - store: NotesSidebarStore, -): void { - if ( - store.headerMounts === 0 && - store.viewMounts === 0 && - notesSidebarStores.get(key) === store - ) { - notesSidebarStores.delete(key); - } -} - function useNotesSidebarState(key: string): { state: NotesSidebarState; store: NotesSidebarStore; @@ -1366,7 +1351,7 @@ function NotesSidebarToggle({ }) { return (