diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.position.test.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.position.test.tsx new file mode 100644 index 0000000000..f63cf2aa01 --- /dev/null +++ b/examples/plugins/t3sidebar/src/SubagentsChip.position.test.tsx @@ -0,0 +1,235 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { PluginSidebarThread } from "@get-bb/plugin-sdk"; + +const app = await loadPluginApp(() => import("../app")); +const childrenChip = app.threadHeaderActions.find( + (slot) => slot.id === "children", +)!; + +function thread( + overrides: Partial = {}, +): PluginSidebarThread { + return { + id: "thr_1", + projectId: "proj_1", + title: "A thread", + titleFallback: null, + parentThreadId: null, + sectionId: null, + originKind: null, + originPluginId: null, + providerId: "codex", + hasPendingInteraction: false, + activity: { + workflows: 0, + backgroundAgents: 0, + backgroundCommands: 0, + planMode: 0, + goals: 0, + }, + indicator: "none", + indicatorLabel: null, + isUnread: false, + isPinned: false, + isArchived: false, + environment: null, + host: null, + createdAt: 100, + updatedAt: 100, + lastReadAt: 100, + latestAttentionAt: 100, + ...overrides, + }; +} + +function renderCompact() { + return renderSlot( + childrenChip, + { + threadId: "parent", + projectId: "proj_1", + isCompactViewport: true, + }, + { + sidebarThreads: { + status: "ready", + threads: [ + thread({ id: "parent" }), + thread({ id: "child", parentThreadId: "parent" }), + ], + projects: [{ id: "proj_1", name: "bb", isPersonal: false }], + }, + }, + ); +} + +function mockCompactGeometry({ + triggerRight, + triggerBottom = 48, + menuWidth, +}: { + triggerRight: number; + triggerBottom?: number; + menuWidth: number; +}) { + vi.spyOn(HTMLElement.prototype, "getBoundingClientRect").mockImplementation( + function (this: HTMLElement) { + if ( + this instanceof HTMLButtonElement && + this.getAttribute("aria-label")?.endsWith("child threads") + ) { + return { right: triggerRight, bottom: triggerBottom } as DOMRect; + } + if (this.getAttribute("role") === "menu") { + return { width: menuWidth } as DOMRect; + } + return { width: 0 } as DOMRect; + }, + ); +} + +function stubVisualViewport({ + left = 0, + top = 0, + width, + height = 844, +}: { + left?: number; + top?: number; + width: number; + height?: number; +}) { + vi.stubGlobal("visualViewport", { + offsetLeft: left, + offsetTop: top, + width, + height, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }); +} + +function setSafeArea({ + left = 0, + top = 0, + right = 0, + bottom = 0, +}: { + left?: number; + top?: number; + right?: number; + bottom?: number; +}) { + const probe = document.querySelector( + "[data-child-menu-safe-area-probe]", + ); + if (!probe) throw new Error("Missing safe-area probe"); + probe.style.paddingLeft = `${left}px`; + probe.style.paddingTop = `${top}px`; + probe.style.paddingRight = `${right}px`; + probe.style.paddingBottom = `${bottom}px`; +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("SubagentsChip menu position", () => { + it("measures the rendered rem-sized menu before clamping", () => { + renderCompact(); + stubVisualViewport({ width: 390 }); + mockCompactGeometry({ triggerRight: 380, menuWidth: 374 }); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.style.maxWidth).toBe("374px"); + expect(menu.style.left).toBe("8px"); + }); + + it("stays right-aligned to the chip while the menu fits on screen", () => { + renderCompact(); + stubVisualViewport({ width: 390 }); + mockCompactGeometry({ triggerRight: 380, menuWidth: 320 }); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + + expect(screen.getByRole("menu", { name: "Child threads" }).style.left).toBe( + "60px", + ); + }); + + it("uses the visual viewport offset and width", () => { + renderCompact(); + stubVisualViewport({ left: 100, width: 300 }); + mockCompactGeometry({ triggerRight: 380, menuWidth: 284 }); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.style.maxWidth).toBe("284px"); + expect(menu.style.left).toBe("108px"); + }); + + it("keeps the menu inside horizontal safe-area insets", () => { + renderCompact(); + stubVisualViewport({ width: 390 }); + setSafeArea({ left: 44, right: 20 }); + mockCompactGeometry({ triggerRight: 380, menuWidth: 310 }); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.style.maxWidth).toBe("310px"); + expect(menu.style.left).toBe("52px"); + }); + + it("keeps the menu inside the visual viewport height", () => { + renderCompact(); + stubVisualViewport({ top: 200, width: 390, height: 300 }); + setSafeArea({ top: 10, bottom: 20 }); + mockCompactGeometry({ + triggerRight: 380, + triggerBottom: 260, + menuWidth: 320, + }); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.style.top).toBe("268px"); + expect(menu.style.maxHeight).toBe("min(32rem, 204px)"); + }); + + it("keeps the desktop menu anchored to its header chip", () => { + renderSlot( + childrenChip, + { + threadId: "parent", + projectId: "proj_1", + isCompactViewport: false, + }, + { + sidebarThreads: { + status: "ready", + threads: [ + thread({ id: "parent" }), + thread({ id: "child", parentThreadId: "parent" }), + ], + projects: [{ id: "proj_1", name: "bb", isPersonal: false }], + }, + }, + ); + + fireEvent.click(screen.getByRole("button", { name: "1 child threads" })); + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.classList.contains("absolute")).toBe(true); + expect(menu.classList.contains("right-0")).toBe(true); + expect(menu.classList.contains("w-80")).toBe(true); + }); +}); diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx new file mode 100644 index 0000000000..f859f5b120 --- /dev/null +++ b/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx @@ -0,0 +1,95 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it } from "vitest"; +import { cleanup, fireEvent, screen } from "@testing-library/react"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { PluginSidebarThread } from "@get-bb/plugin-sdk"; + +const app = await loadPluginApp(() => import("../app")); +const childrenChip = app.threadHeaderActions.find( + (slot) => slot.id === "children", +)!; + +function thread( + overrides: Partial = {}, +): PluginSidebarThread { + return { + id: "thr_1", + projectId: "proj_1", + title: "A thread", + titleFallback: null, + parentThreadId: null, + sectionId: null, + originKind: null, + originPluginId: null, + providerId: "codex", + hasPendingInteraction: false, + activity: { + workflows: 0, + backgroundAgents: 0, + backgroundCommands: 0, + planMode: 0, + goals: 0, + }, + indicator: "none", + indicatorLabel: null, + isUnread: false, + isPinned: false, + isArchived: false, + environment: null, + host: null, + createdAt: 100, + updatedAt: 100, + lastReadAt: 100, + latestAttentionAt: 100, + ...overrides, + }; +} + +afterEach(cleanup); + +describe("SubagentsChip child list", () => { + it("caps a long menu and makes the child list scrollable", () => { + renderSlot( + childrenChip, + { + threadId: "parent", + projectId: "proj_1", + isCompactViewport: true, + }, + { + sidebarThreads: { + status: "ready", + threads: [ + thread({ id: "parent", title: "Parent" }), + ...Array.from({ length: 26 }, (_, index) => + thread({ + id: `child_${index}`, + title: `Child ${index + 1}`, + parentThreadId: "parent", + createdAt: index, + }), + ), + ], + projects: [{ id: "proj_1", name: "bb", isPersonal: false }], + }, + }, + ); + + fireEvent.click(screen.getByRole("button", { name: "26 child threads" })); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + const list = menu.querySelector("ul"); + expect(menu.classList.contains("flex")).toBe(true); + expect( + menu.classList.contains("max-h-[min(32rem,calc(100dvh-6rem))]"), + ).toBe(true); + expect(list?.classList.contains("min-h-0")).toBe(true); + expect(list?.classList.contains("overflow-y-auto")).toBe(true); + expect(list?.classList.contains("overscroll-contain")).toBe(true); + expect(list?.classList.contains("touch-pan-y")).toBe(true); + expect(list?.classList.contains("[-webkit-overflow-scrolling:touch]")).toBe( + true, + ); + expect(screen.getAllByRole("menuitem")).toHaveLength(26); + }); +}); diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.tsx index 90574b1c94..cb0957b3f9 100644 --- a/examples/plugins/t3sidebar/src/SubagentsChip.tsx +++ b/examples/plugins/t3sidebar/src/SubagentsChip.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useLayoutEffect, useRef, useState } from "react"; import { experimental_useSidebarThreadActions as useSidebarThreadActions, experimental_useSidebarThreads as useSidebarThreads, @@ -11,6 +11,45 @@ import { StatusGlyph } from "./StatusGlyph"; import { childrenOf, threadDisplayTitle } from "./inbox"; const MAX_DISCS = 3; +const CHILD_MENU_VIEWPORT_GUTTER = 8; + +interface CompactViewportBounds { + left: number; + top: number; + width: number; + height: number; + safeAreaLeft: number; + safeAreaTop: number; + safeAreaRight: number; + safeAreaBottom: number; +} + +function clampCompactMenuLeft({ + triggerRight, + menuWidth, + viewport, +}: { + triggerRight: number; + menuWidth: number; + viewport: CompactViewportBounds; +}) { + const minLeft = + viewport.left + viewport.safeAreaLeft + CHILD_MENU_VIEWPORT_GUTTER; + const maxLeft = Math.max( + minLeft, + viewport.left + + viewport.width - + viewport.safeAreaRight - + CHILD_MENU_VIEWPORT_GUTTER - + menuWidth, + ); + return Math.min(Math.max(triggerRight - menuWidth, minLeft), maxLeft); +} + +function parsePixelValue(value: string) { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} /** * The home for child threads the flat list hides: a chip in the thread header @@ -27,6 +66,97 @@ export function SubagentsChip({ const { threads } = useSidebarThreads(); const actions = useSidebarThreadActions(); const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const safeAreaProbeRef = useRef(null); + const [compactViewport, setCompactViewport] = + useState(null); + const [compactMenuLeft, setCompactMenuLeft] = useState( + CHILD_MENU_VIEWPORT_GUTTER, + ); + const [compactMenuTop, setCompactMenuTop] = useState( + CHILD_MENU_VIEWPORT_GUTTER, + ); + const [compactMenuMaxHeight, setCompactMenuMaxHeight] = useState< + string | undefined + >(); + + const updateCompactViewport = useCallback(() => { + const visualViewport = window.visualViewport; + const safeAreaProbe = safeAreaProbeRef.current; + const safeArea = safeAreaProbe ? getComputedStyle(safeAreaProbe) : null; + const next: CompactViewportBounds = { + left: visualViewport?.offsetLeft ?? 0, + top: visualViewport?.offsetTop ?? 0, + width: visualViewport?.width ?? window.innerWidth, + height: visualViewport?.height ?? window.innerHeight, + safeAreaLeft: parsePixelValue(safeArea?.paddingLeft ?? ""), + safeAreaTop: parsePixelValue(safeArea?.paddingTop ?? ""), + safeAreaRight: parsePixelValue(safeArea?.paddingRight ?? ""), + safeAreaBottom: parsePixelValue(safeArea?.paddingBottom ?? ""), + }; + setCompactViewport((current) => + current?.left === next.left && + current.top === next.top && + current.width === next.width && + current.height === next.height && + current.safeAreaLeft === next.safeAreaLeft && + current.safeAreaTop === next.safeAreaTop && + current.safeAreaRight === next.safeAreaRight && + current.safeAreaBottom === next.safeAreaBottom + ? current + : next, + ); + }, []); + + useLayoutEffect(() => { + if (!open || !isCompactViewport) return; + + updateCompactViewport(); + const viewport = window.visualViewport; + window.addEventListener("resize", updateCompactViewport); + viewport?.addEventListener("resize", updateCompactViewport); + viewport?.addEventListener("scroll", updateCompactViewport); + return () => { + window.removeEventListener("resize", updateCompactViewport); + viewport?.removeEventListener("resize", updateCompactViewport); + viewport?.removeEventListener("scroll", updateCompactViewport); + }; + }, [isCompactViewport, open, updateCompactViewport]); + + useLayoutEffect(() => { + if (!open || !isCompactViewport || !compactViewport) return; + const trigger = triggerRef.current; + const menu = menuRef.current; + if (!trigger || !menu) return; + const triggerRect = trigger.getBoundingClientRect(); + const menuWidth = menu.getBoundingClientRect().width; + if (menuWidth <= 0) return; + setCompactMenuLeft( + clampCompactMenuLeft({ + triggerRight: triggerRect.right, + menuWidth, + viewport: compactViewport, + }), + ); + const minTop = + compactViewport.top + + compactViewport.safeAreaTop + + CHILD_MENU_VIEWPORT_GUTTER; + const viewportBottom = + compactViewport.top + + compactViewport.height - + compactViewport.safeAreaBottom - + CHILD_MENU_VIEWPORT_GUTTER; + const top = Math.min( + Math.max(triggerRect.bottom + CHILD_MENU_VIEWPORT_GUTTER, minTop), + Math.max(minTop, viewportBottom), + ); + setCompactMenuTop(top); + setCompactMenuMaxHeight( + `min(32rem, ${Math.max(0, viewportBottom - top)}px)`, + ); + }, [compactViewport, isCompactViewport, open]); const children = childrenOf(threads, threadId); if (children.length === 0) return null; @@ -37,10 +167,14 @@ export function SubagentsChip({ return ( + {isCompactViewport ? ( + + ) : null} {open ? ( <> - {/* Click-away. The header is a short row, so the list itself is - absolutely positioned rather than inline. */} + {/* Click-away. Wide headers anchor the menu to this chip; compact + headers pin it to the viewport so it cannot run off-screen. */} setOpen(false)} aria-hidden />
Children @@ -70,7 +240,7 @@ export function SubagentsChip({ {children.length}
-
    +
      {children.map((child) => (