From c5194233cfd752c66e0e73ba5324b462c6e3ebdf Mon Sep 17 00:00:00 2001 From: Bersabel Tadesse Date: Wed, 19 Aug 2026 12:33:39 -0700 Subject: [PATCH] Refine right-sidebar split tab chrome --- .../SecondaryPanelTabStrip.test.ts | 17 +- .../SecondaryPanelTabStrip.tsx | 46 ++- .../SidebarSplitContainer.test.tsx | 308 +++++++++++------- .../secondary-panel/SidebarSplitContainer.tsx | 119 +++++-- ...eadSecondaryPanel.collapseControl.test.tsx | 89 ++++- .../ThreadSecondaryPanel.stories.tsx | 121 ++++++- .../secondary-panel/ThreadSecondaryPanel.tsx | 199 ++++++++--- .../sidebarSplitLayout.test.ts | 19 ++ .../secondary-panel/sidebarSplitLayout.ts | 2 +- .../secondary-panel/terminalPanelTabs.test.ts | 49 ++- .../secondary-panel/terminalPanelTabs.ts | 19 +- .../thread/terminal/ThreadTerminalPanel.tsx | 3 + .../useThreadTerminalController.test.ts | 22 ++ .../terminal/useThreadTerminalController.ts | 15 + apps/app/src/components/ui/tab-pill.tsx | 14 +- .../thread-detail/PaneMaximizeButton.tsx | 19 +- .../thread-detail/SplitThreadArea.test.tsx | 14 + .../views/thread-detail/SplitThreadArea.tsx | 2 +- .../views/thread-detail/ThreadDetailView.tsx | 164 ++++++++-- 19 files changed, 985 insertions(+), 256 deletions(-) diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts index e941cf9612..0df7797568 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts @@ -16,7 +16,7 @@ afterEach(() => { describe("secondary panel tab-strip edge fades", () => { it("uses the themed edge fade", () => { - expect(SECONDARY_PANEL_TAB_STRIP_FADE_TONE).toBe("sidebar"); + expect(SECONDARY_PANEL_TAB_STRIP_FADE_TONE).toBe("surface-raised"); }); it("observes the intrinsic tab row so async title changes refresh overflow", () => { @@ -87,6 +87,8 @@ describe("secondary panel tab-strip edge fades", () => { ); expect(leftButton?.classList.contains("w-0")).toBe(true); expect(rightButton?.classList.contains("w-0")).toBe(true); + expect(strip?.hasAttribute("data-overflowing")).toBe(false); + expect(strip?.classList.contains("border-border-hairline")).toBe(false); const rightFade = container.querySelector("[data-overflow-fade='right']"); expect(rightFade?.classList.contains("opacity-0")).toBe(true); @@ -122,11 +124,12 @@ describe("secondary panel tab-strip edge fades", () => { expect(leftButton?.tabIndex).toBe(-1); expect(rightButton?.classList.contains("opacity-100")).toBe(true); expect(rightButton?.tabIndex).toBe(0); - expect(rightButton?.classList.contains("bg-sidebar")).toBe(true); - expect( - rightButton?.classList.contains("hover:bg-surface-raised-solid"), - ).toBe(true); - expect(rightButton?.classList.contains("hover:bg-state-hover")).toBe(false); + expect(strip?.hasAttribute("data-overflowing")).toBe(true); + expect(strip?.classList.contains("rounded-md")).toBe(true); + expect(strip?.classList.contains("border-border-hairline")).toBe(true); + expect(strip?.classList.contains("bg-surface-raised-solid")).toBe(true); + expect(rightButton?.classList.contains("bg-transparent")).toBe(true); + expect(rightButton?.classList.contains("hover:bg-state-hover")).toBe(true); const scrollBy = vi.fn(); Object.defineProperty(viewport!, "scrollBy", { @@ -154,6 +157,8 @@ describe("secondary panel tab-strip edge fades", () => { }); expect(leftButton?.classList.contains("w-0")).toBe(true); expect(rightButton?.classList.contains("w-0")).toBe(true); + expect(strip?.hasAttribute("data-overflowing")).toBe(false); + expect(strip?.classList.contains("border-border-hairline")).toBe(false); expect(document.activeElement).toBe( container.querySelector('button[aria-pressed="true"]'), ); diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx index 0ed23ff0a7..44deb0c8b3 100644 --- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx +++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx @@ -34,7 +34,7 @@ import { OverflowFade, type OverflowFadeTone, } from "@/components/ui/overflow-fade"; -import { TabPill } from "@/components/ui/tab-pill"; +import { TabPill, type TabPillActiveTreatment } from "@/components/ui/tab-pill"; import { useDragClickSuppression } from "@/components/ui/use-drag-click-suppression"; import { cn } from "@bb/shared-ui/lib/utils"; import { @@ -57,7 +57,8 @@ const TAB_STRIP_SCROLL_BUTTON_CLASS = // Slack so sub-pixel scroll offsets don't leave an overflow cue at a hard edge. const EDGE_EPSILON_PX = 1; -export const SECONDARY_PANEL_TAB_STRIP_FADE_TONE: OverflowFadeTone = "sidebar"; +export const SECONDARY_PANEL_TAB_STRIP_FADE_TONE: OverflowFadeTone = + "surface-raised"; /** * Stand-in for dnd-kit's TouchSensor while the panel is closed or has nothing @@ -86,6 +87,7 @@ const INITIAL_OVERFLOW_STATE: TabStripOverflowState = { }; export interface SecondaryPanelTabStripProps { + activeClassName?: string; fileTabs: SecondaryPanelFileTab[]; onBeginTabDrag?: ( tabId: string, @@ -100,11 +102,12 @@ export interface SecondaryPanelTabStripProps { * every page. */ isPanelOpen: boolean; - activeTreatment?: "fill" | "underline"; + activeTreatment?: TabPillActiveTreatment; } interface SortableFileTabProps { - activeTreatment: "fill" | "underline"; + activeClassName?: string; + activeTreatment: TabPillActiveTreatment; activeTabRef: RefObject; dragDisabled: boolean; noDragClass: string | null; @@ -125,6 +128,7 @@ interface SortableFileTabProps { * (covering pointer, keyboard, and programmatic selection). */ export function SecondaryPanelTabStrip({ + activeClassName, fileTabs, onBeginTabDrag, onReorderTab, @@ -436,6 +440,7 @@ export function SecondaryPanelTabStrip({ {fileTabs.map((tab) => ( {draggingTab === null ? null : ( - + )} , document.body, @@ -471,6 +480,7 @@ export function SecondaryPanelTabStrip({ onBeginTabDrag, draggingTab, activeTreatment, + activeClassName, ], ); @@ -482,7 +492,12 @@ export function SecondaryPanelTabStrip({
- +
); } @@ -626,9 +646,12 @@ function TabStripScrollButton({ aria-label={label} onClick={onClick} className={cn( - "z-20 shrink-0 bg-sidebar text-muted-foreground shadow-none hover:bg-surface-raised-solid hover:text-foreground focus-visible:bg-sidebar", + "z-20 shrink-0 text-muted-foreground shadow-none", hasOverflow - ? TAB_STRIP_SCROLL_BUTTON_CLASS + ? [ + TAB_STRIP_SCROLL_BUTTON_CLASS, + "bg-transparent hover:bg-state-hover hover:text-foreground focus-visible:bg-state-hover", + ] : "h-7 w-0 overflow-hidden p-0 max-md:pointer-coarse:h-9", "transition-opacity", canScroll @@ -644,10 +667,12 @@ function TabStripScrollButton({ function FileTab({ tab, + activeClassName, activeTreatment, }: { tab: SecondaryPanelFileTab; - activeTreatment: "fill" | "underline"; + activeClassName?: string; + activeTreatment: TabPillActiveTreatment; }) { const title = tab.statusLabel === null @@ -660,6 +685,7 @@ function FileTab({ secondaryLabel={tab.statusLabel === null ? null : `(${tab.statusLabel})`} title={title} isActive={tab.isActive} + activeClassName={activeClassName} activeTreatment={activeTreatment} onSelect={tab.onSelect} labelMaxWidthClass="max-w-[160px]" diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx index f5a485ee35..facc12f1e5 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx @@ -48,20 +48,6 @@ function createTwoPaneState(): SidebarSplitState { ); } -function createStackedPaneState(): SidebarSplitState { - const initial = createSidebarSplitState( - TABS.map((tab) => tab.id), - "tab-a", - ); - return moveSidebarTab( - initial, - initial.layout.focusedPaneId, - "tab-b", - { paneId: initial.layout.focusedPaneId, zone: "bottom" }, - { groupId: "group-b" }, - ); -} - function persistState(state: SidebarSplitState): void { window.localStorage.setItem( sidebarSplitStorageKey(PANEL_STATE_ID), @@ -225,8 +211,6 @@ describe("SidebarSplitContainer", () => { it.each([ ["left", "flex-row", "tab-a,tab-b"], ["right", "flex-row", "tab-b,tab-a"], - ["top", "flex-col", "tab-a,tab-b"], - ["bottom", "flex-col", "tab-b,tab-a"], ] as const)( "moves the active tab to the supported %s position without dragging", (side, directionClass, expectedOrder) => { @@ -262,6 +246,96 @@ describe("SidebarSplitContainer", () => { }, ); + it("rejects top and bottom tab tear-out zones while retaining side splits", () => { + Object.defineProperty(document, "elementsFromPoint", { + configurable: true, + value: vi.fn(() => []), + }); + renderContainer({ + renderPane: ({ onBeginTabDrag }) => ( + + ), + }); + + const tab = screen.getByRole("button", { name: "Drag B" }); + fireEvent.pointerDown(tab, { + button: 0, + clientX: 200, + clientY: 20, + pointerId: 1, + }); + fireEvent.pointerMove(window, { + clientX: 200, + clientY: 40, + pointerId: 1, + }); + fireEvent.pointerUp(window, { + clientX: 200, + clientY: 40, + pointerId: 1, + }); + expect(document.querySelectorAll("[data-split-pane-id]")).toHaveLength(0); + + fireEvent.pointerDown(tab, { + button: 0, + clientX: 200, + clientY: 300, + pointerId: 2, + }); + fireEvent.pointerMove(window, { + clientX: 200, + clientY: 560, + pointerId: 2, + }); + fireEvent.pointerUp(window, { + clientX: 200, + clientY: 560, + pointerId: 2, + }); + expect(document.querySelectorAll("[data-split-pane-id]")).toHaveLength(0); + + fireEvent.pointerDown(tab, { + button: 0, + clientX: 200, + clientY: 300, + pointerId: 3, + }); + fireEvent.pointerMove(window, { + clientX: 20, + clientY: 300, + pointerId: 3, + }); + fireEvent.pointerUp(window, { + clientX: 20, + clientY: 300, + pointerId: 3, + }); + expect(document.querySelectorAll("[data-split-pane-id]")).toHaveLength(2); + }); + it("positions the focused active tab even when the control is in the outer pane", () => { const split = createTwoPaneState(); const firstPane = @@ -280,24 +354,22 @@ describe("SidebarSplitContainer", () => { {showOuterControls ? ( ) : null} ), }); - fireEvent.click( - screen.getByRole("button", { name: "Move focused bottom" }), - ); + fireEvent.click(screen.getByRole("button", { name: "Move focused left" })); expect( screen .getAllByTestId("active-pane-tab") .map((tab) => tab.textContent) .join(","), - ).toBe("tab-b,tab-a"); + ).toBe("tab-a,tab-b"); }); it("keeps stateful pane content attached to pane identity after a move", () => { @@ -371,14 +443,21 @@ describe("SidebarSplitContainer", () => { ); expect(headerSeparator).toBeInstanceOf(HTMLElement); expect(bodySeparator).toBeInstanceOf(HTMLElement); + const headerBodyDivider = document.querySelector( + "[data-sidebar-split-header-divider]", + ); + expect(headerBodyDivider).toBeInstanceOf(HTMLElement); + expect(headerBodyDivider?.className).toContain("h-px"); + expect(headerBodyDivider?.className).toContain( + "bg-border-seam-vertical/40", + ); if ( !(headerSeparator instanceof HTMLElement) || !(bodySeparator instanceof HTMLElement) ) { return; } - expect(headerSeparator.className).toContain("bg-border-seam-vertical/60"); - expect(bodySeparator.className).toContain("bg-transparent"); + expect(bodySeparator.className).not.toContain("bg-transparent"); const headerPrevious = headerSeparator.previousElementSibling; const headerNext = headerSeparator.nextElementSibling; @@ -422,6 +501,21 @@ describe("SidebarSplitContainer", () => { }); fireEvent.pointerDown(bodyHitTarget, { clientX: 400, pointerId: 1 }); + expect(bodySeparator.dataset.dragging).toBe("true"); + expect(headerSeparator.dataset.dragging).toBe("true"); + expect(headerSeparator.className).not.toContain("bg-transparent"); + expect(headerSeparator.className).toContain("bg-border-seam-vertical/40"); + expect(headerSeparator.className).toContain( + "hover:bg-border-seam-vertical/50", + ); + expect(headerSeparator.className).toContain("data-[dragging]:bg-ring/40"); + expect(bodySeparator.className).toContain("bg-border-seam-vertical/40"); + expect(bodySeparator.className).toContain( + "hover:bg-border-seam-vertical/50", + ); + expect(headerSeparator.className).toBe(bodySeparator.className); + expect(headerSeparator.className).not.toContain("hover:bg-ring/40"); + expect(bodySeparator.className).not.toContain("hover:bg-ring/40"); fireEvent.pointerMove(bodyHitTarget, { clientX: 600, pointerId: 1 }); expect(headerPrevious.style.flex).toBe(bodyPrevious.style.flex); @@ -445,6 +539,8 @@ describe("SidebarSplitContainer", () => { fireEvent.pointerUp(bodyHitTarget, { clientX: 600, pointerId: 1 }); expect(headerPrevious.style.flex).toBe(bodyPrevious.style.flex); expect(headerNext.style.flex).toBe(bodyNext.style.flex); + expect(bodySeparator.dataset.dragging).toBeUndefined(); + expect(headerSeparator.dataset.dragging).toBeUndefined(); }); it("resizes the adjacent panes from the shared-header separator", () => { @@ -622,101 +718,15 @@ describe("SidebarSplitContainer", () => { expect(headerSeparator.dataset.dragging).toBeUndefined(); }); - it("resizes stacked panes from their shared-header separator and restores cancellation", () => { - persistState(createStackedPaneState()); - renderContainer({ - renderPane: ({ paneId }) =>
{paneId}
, - renderSplitHeader: ({ renderTabGroups }) => ( -
{renderTabGroups(({ paneId }) =>
{paneId}
)}
- ), - }); - - const headerSeparator = screen - .getAllByRole("separator") - .find( - (separator) => - separator.parentElement?.dataset.sidebarSplitSurface === "header", - ); - expect(headerSeparator).toBeInstanceOf(HTMLElement); - expect(headerSeparator?.className).toContain( - "bg-border-seam-vertical/60", - ); - const bodySeparator = screen.getByRole("separator", { - name: "Resize stacked right panel panes", - }); - const hitTarget = headerSeparator?.firstElementChild; - const headerPrevious = headerSeparator?.previousElementSibling; - const headerNext = headerSeparator?.nextElementSibling; - const bodyPrevious = bodySeparator.previousElementSibling; - const bodyNext = bodySeparator.nextElementSibling; - if ( - !(headerSeparator instanceof HTMLElement) || - !(hitTarget instanceof HTMLElement) || - !(headerPrevious instanceof HTMLElement) || - !(headerNext instanceof HTMLElement) || - !(bodyPrevious instanceof HTMLElement) || - !(bodyNext instanceof HTMLElement) - ) { - throw new Error("Expected stacked header and body resize elements"); - } - Object.defineProperty(hitTarget, "setPointerCapture", { - configurable: true, - value: vi.fn(), - }); - vi.spyOn(headerPrevious, "getBoundingClientRect").mockReturnValue({ - bottom: 48, - height: 48, - left: 0, - right: 400, - top: 0, - width: 400, - x: 0, - y: 0, - toJSON: () => ({}), - }); - vi.spyOn(headerNext, "getBoundingClientRect").mockReturnValue({ - bottom: 48, - height: 48, - left: 401, - right: 801, - top: 0, - width: 400, - x: 401, - y: 0, - toJSON: () => ({}), - }); - - fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 3 }); - fireEvent.pointerMove(hitTarget, { clientX: 520, pointerId: 3 }); - expect(headerPrevious.style.flex).toBe(bodyPrevious.style.flex); - expect(headerNext.style.flex).toBe(bodyNext.style.flex); - expect(Number.parseFloat(headerPrevious.style.flex)).toBeCloseTo(0.649, 3); - - fireEvent.pointerUp(hitTarget, { clientX: 600, pointerId: 3 }); - expect(headerPrevious.style.flex).toBe(bodyPrevious.style.flex); - expect(headerNext.style.flex).toBe(bodyNext.style.flex); - expect(Number.parseFloat(bodyPrevious.style.flex)).toBeCloseTo(0.749, 3); - expect(Number.parseFloat(bodyNext.style.flex)).toBeCloseTo(0.251, 3); - - const committedPreviousFlex = headerPrevious.style.flex; - const committedNextFlex = headerNext.style.flex; - fireEvent.pointerDown(hitTarget, { clientX: 600, pointerId: 4 }); - fireEvent.pointerMove(hitTarget, { clientX: 320, pointerId: 4 }); - expect(headerPrevious.style.flex).not.toBe(committedPreviousFlex); - expect(bodyPrevious.style.flex).toBe(headerPrevious.style.flex); - fireEvent.pointerCancel(hitTarget, { clientX: 320, pointerId: 4 }); - expect(headerPrevious.style.flex).toBe(committedPreviousFlex); - expect(headerNext.style.flex).toBe(committedNextFlex); - expect(bodyPrevious.style.flex).toBe(committedPreviousFlex); - expect(bodyNext.style.flex).toBe(committedNextFlex); - }); - it("restores both adjacent flex values after pointer cancellation", () => { persistState(createTwoPaneState()); renderContainer({ renderPane: ({ paneId }) =>
{paneId}
, }); + expect( + document.querySelector("[data-sidebar-split-header-divider]"), + ).toBeNull(); const separator = screen.getByRole("separator"); expect(separator.className).toContain("bg-transparent"); expect(separator.className).not.toContain("bg-border-seam"); @@ -752,6 +762,78 @@ describe("SidebarSplitContainer", () => { expect(previous.style.flex).toBe(previousFlex); expect(next.style.flex).toBe(nextFlex); expect(document.body.style.userSelect).toBe(""); + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 2 }); + fireEvent.pointerMove(hitTarget, { clientX: 520, pointerId: 2 }); + fireEvent.blur(window); + expect(previous.style.flex).toBe(previousFlex); + expect(next.style.flex).toBe(nextFlex); + expect(document.body.style.userSelect).toBe(""); + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 3 }); + fireEvent.pointerMove(hitTarget, { clientX: 520, pointerId: 3 }); + fireEvent.lostPointerCapture(hitTarget, { pointerId: 3 }); + expect(previous.style.flex).toBe(previousFlex); + expect(next.style.flex).toBe(nextFlex); + expect(document.body.style.userSelect).toBe(""); + }); + + it("cancels an active resize when the split container unmounts", () => { + persistState(createTwoPaneState()); + const storageKey = sidebarSplitStorageKey(PANEL_STATE_ID); + const initialStorage = window.localStorage.getItem(storageKey); + const view = renderContainer({ + renderPane: ({ paneId }) =>
{paneId}
, + }); + const separator = screen.getByRole("separator"); + const hitTarget = separator.firstElementChild; + const previous = separator.previousElementSibling; + const next = separator.nextElementSibling; + if ( + !(hitTarget instanceof HTMLElement) || + !(previous instanceof HTMLElement) || + !(next instanceof HTMLElement) + ) { + throw new Error("Expected resize elements"); + } + Object.defineProperty(hitTarget, "setPointerCapture", { + configurable: true, + value: vi.fn(), + }); + vi.spyOn(previous, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 0, + right: 400, + top: 0, + width: 400, + x: 0, + y: 0, + toJSON: () => ({}), + }); + vi.spyOn(next, "getBoundingClientRect").mockReturnValue({ + bottom: 600, + height: 600, + left: 401, + right: 801, + top: 0, + width: 400, + x: 401, + y: 0, + toJSON: () => ({}), + }); + const initialFlex = [previous.style.flex, next.style.flex]; + + fireEvent.pointerDown(hitTarget, { clientX: 400, pointerId: 41 }); + fireEvent.pointerMove(hitTarget, { clientX: 560, pointerId: 41 }); + expect(document.body.style.userSelect).toBe("none"); + expect(previous.style.flex).not.toBe(initialFlex[0]); + + view.unmount(); + + expect(document.body.style.userSelect).toBe(""); + expect([previous.style.flex, next.style.flex]).toEqual(initialFlex); + expect(window.localStorage.getItem(storageKey)).toBe(initialStorage); }); it("does not write a canonical layout or rewrite a focused-pane no-op", () => { diff --git a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx index 4c39b3c9de..fb560dedb8 100644 --- a/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx +++ b/apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx @@ -50,6 +50,9 @@ import { import type { SecondaryPanelTabReorderRequest } from "./secondaryPanelFileTab"; const PANE_DRAG_ENGAGE_DISTANCE_PX = 7; +const TAB_SPLIT_DIVIDER_CLASS = + "bg-border-seam-vertical/40 hover:bg-border-seam-vertical/50 data-[dragging]:bg-ring/40"; +type SidebarTabSplitSide = Extract; export interface SidebarSplitTabDescriptor { id: string; @@ -68,10 +71,11 @@ export interface SidebarSplitPaneRenderArgs { ) => void; onReorderTab: (request: SecondaryPanelTabReorderRequest) => void; onFocusPane: () => void; - onMoveActiveTabToSide?: (side: SplitSide) => void; + onMoveActiveTabToSide?: (side: SidebarTabSplitSide) => void; onSelectTab: (tabId: string) => void; paneId: string; showOuterControls: boolean; + visibleActiveTabIds: readonly string[]; } export interface SidebarSplitHeaderRenderArgs { @@ -85,6 +89,7 @@ interface SidebarSplitContainerProps { activeTabId: string; onActivateTab: (tabId: string) => void; onGlobalTabReorder: (request: SecondaryPanelTabReorderRequest) => void; + onVisibleActiveTabIdsChange?: (tabIds: readonly string[]) => void; panelStateId: string; renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; renderSplitHeader?: (args: SidebarSplitHeaderRenderArgs) => ReactNode; @@ -95,6 +100,7 @@ export function SidebarSplitContainer({ activeTabId, onActivateTab, onGlobalTabReorder, + onVisibleActiveTabIdsChange, panelStateId, renderPane, renderSplitHeader, @@ -125,11 +131,23 @@ export function SidebarSplitContainer({ const dimsInactiveSplits = useAtomValue(dimInactiveSplitsAtom); const paneCount = countPanes(state.layout.root); const hasMultiplePanes = paneCount > 1; + const visibleActiveTabIds = useMemo( + () => + listPanes(state.layout.root).flatMap((pane) => { + const group = getSidebarGroupForPane(state, pane.paneId); + return group === null ? [] : [group.activeTabId]; + }), + [state], + ); useEffect(() => { stateRef.current = state; }, [state]); + useEffect(() => { + onVisibleActiveTabIdsChange?.(visibleActiveTabIds); + }, [onVisibleActiveTabIdsChange, visibleActiveTabIds]); + useEffect(() => { const shouldFollowExternalSelection = previousActiveTabId.current !== activeTabId; @@ -231,7 +249,7 @@ export function SidebarSplitContainer({ ); const moveActiveTabToSide = useCallback( - (side: SplitSide) => { + (side: SidebarTabSplitSide) => { commitState((current) => { const paneId = current.layout.focusedPaneId; const sourceGroup = getSidebarGroupForPane(current, paneId); @@ -259,10 +277,6 @@ export function SidebarSplitContainer({ return rect.x; case "right": return -(rect.x + rect.w); - case "top": - return rect.y; - case "bottom": - return -(rect.y + rect.h); } }; return edge(a) - edge(b); @@ -277,6 +291,7 @@ export function SidebarSplitContainer({ const moveTab = useCallback( (sourcePaneId: string, tabId: string, target: SplitDropTarget) => { + if (target.zone === "top" || target.zone === "bottom") return; const groupId = nextSidebarSplitGroupId(stateRef.current); commitState( (current) => @@ -327,6 +342,7 @@ export function SidebarSplitContainer({ ); }, decide: (targetPaneId, zone) => { + if (zone === "top" || zone === "bottom") return null; if (targetPaneId === sourcePaneId) { if (zone === "center" || (sourceGroup?.tabIds.length ?? 0) <= 1) { return null; @@ -403,6 +419,7 @@ export function SidebarSplitContainer({ onSelectTab: (tabId) => selectTab(firstPane.paneId, tabId), paneId: firstPane.paneId, showOuterControls: true, + visibleActiveTabIds, }); } @@ -427,6 +444,7 @@ export function SidebarSplitContainer({ onSelectTab: (tabId: string) => selectTab(pane.paneId, tabId), paneId: pane.paneId, showOuterControls: index === splitPanes.length - 1, + visibleActiveTabIds, }, ]; }); @@ -452,6 +470,13 @@ export function SidebarSplitContainer({ data-sidebar-split-container="" > {splitHeader} + {splitHeader !== undefined ? ( +
+ ) : null}
, ) => void; onFocusPane: (paneId: string) => void; - onMoveActiveTabToSide?: (side: SplitSide) => void; + onMoveActiveTabToSide?: (side: SidebarTabSplitSide) => void; onReorderTab: ( paneId: string, request: SecondaryPanelTabReorderRequest, @@ -498,6 +524,7 @@ interface SidebarSplitTreeProps { path: number[]; renderPane: (args: SidebarSplitPaneRenderArgs) => ReactNode; state: SidebarSplitState; + visibleActiveTabIds: readonly string[]; } interface SidebarSplitHeaderTreeProps { @@ -578,7 +605,7 @@ function SidebarSplitTree(props: SidebarSplitTreeProps) { {index > 0 ? (