From dd99226c4afdd1416b0be20cfbb5e0c248634489 Mon Sep 17 00:00:00 2001 From: Willhong Date: Fri, 21 Aug 2026 08:37:48 +0900 Subject: [PATCH 1/7] fix(t3sidebar): clamp compact child menu --- .../plugins/t3sidebar/src/SubagentsChip.tsx | 62 +++++++++++++++++-- 1 file changed, 57 insertions(+), 5 deletions(-) diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.tsx index 90574b1c94..307158a3fc 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,21 @@ import { StatusGlyph } from "./StatusGlyph"; import { childrenOf, threadDisplayTitle } from "./inbox"; const MAX_DISCS = 3; +const CHILD_MENU_WIDTH = 320; +const CHILD_MENU_VIEWPORT_GUTTER = 8; + +function clampCompactMenuLeft(triggerRight: number, viewportWidth: number) { + const menuWidth = Math.min( + CHILD_MENU_WIDTH, + Math.max(0, viewportWidth - CHILD_MENU_VIEWPORT_GUTTER * 2), + ); + const minLeft = CHILD_MENU_VIEWPORT_GUTTER; + const maxLeft = Math.max( + minLeft, + viewportWidth - menuWidth - CHILD_MENU_VIEWPORT_GUTTER, + ); + return Math.min(Math.max(triggerRight - menuWidth, minLeft), maxLeft); +} /** * The home for child threads the flat list hides: a chip in the thread header @@ -27,6 +42,33 @@ export function SubagentsChip({ const { threads } = useSidebarThreads(); const actions = useSidebarThreadActions(); const [open, setOpen] = useState(false); + const triggerRef = useRef(null); + const [compactMenuLeft, setCompactMenuLeft] = useState( + CHILD_MENU_VIEWPORT_GUTTER, + ); + + const updateCompactMenuLeft = useCallback(() => { + const trigger = triggerRef.current; + if (!trigger) return; + setCompactMenuLeft( + clampCompactMenuLeft(trigger.getBoundingClientRect().right, innerWidth), + ); + }, []); + + useLayoutEffect(() => { + if (!open || !isCompactViewport) return; + + updateCompactMenuLeft(); + const viewport = window.visualViewport; + window.addEventListener("resize", updateCompactMenuLeft); + viewport?.addEventListener("resize", updateCompactMenuLeft); + viewport?.addEventListener("scroll", updateCompactMenuLeft); + return () => { + window.removeEventListener("resize", updateCompactMenuLeft); + viewport?.removeEventListener("resize", updateCompactMenuLeft); + viewport?.removeEventListener("scroll", updateCompactMenuLeft); + }; + }, [isCompactViewport, open, updateCompactMenuLeft]); const children = childrenOf(threads, threadId); if (children.length === 0) return null; @@ -37,10 +79,14 @@ export function SubagentsChip({ return ( {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)} @@ -62,7 +108,13 @@ export function SubagentsChip({
Children From 9ecd643ffaccb22e6206468eabaa4a576c2a1109 Mon Sep 17 00:00:00 2001 From: Willhong Date: Fri, 21 Aug 2026 08:37:52 +0900 Subject: [PATCH 2/7] test(t3sidebar): cover compact menu placement --- .../t3sidebar/src/SubagentsChip.test.tsx | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 examples/plugins/t3sidebar/src/SubagentsChip.test.tsx diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx new file mode 100644 index 0000000000..0794f44ca4 --- /dev/null +++ b/examples/plugins/t3sidebar/src/SubagentsChip.test.tsx @@ -0,0 +1,134 @@ +// @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 }], + }, + }, + ); +} + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); + +describe("SubagentsChip menu position", () => { + it("clamps only when right alignment would cross the viewport gutter", () => { + renderCompact(); + vi.stubGlobal("innerWidth", 390); + const trigger = screen.getByRole("button", { name: "1 child threads" }); + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + right: 250, + } as DOMRect); + + fireEvent.click(trigger); + + const menu = screen.getByRole("menu", { name: "Child threads" }); + expect(menu.classList.contains("fixed")).toBe(true); + expect(menu.classList.contains("w-80")).toBe(true); + expect(menu.classList.contains("max-w-[calc(100vw-1rem)]")).toBe(true); + expect(menu.style.left).toBe("8px"); + }); + + it("stays right-aligned to the chip while the menu fits on screen", () => { + renderCompact(); + vi.stubGlobal("innerWidth", 390); + const trigger = screen.getByRole("button", { name: "1 child threads" }); + vi.spyOn(trigger, "getBoundingClientRect").mockReturnValue({ + right: 380, + } as DOMRect); + + fireEvent.click(trigger); + + expect( + screen.getByRole("menu", { name: "Child threads" }).style.left, + ).toBe("60px"); + }); + + 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); + }); +}); From 918dffcea0e19a152cb936a8516f155f3a0b914d Mon Sep 17 00:00:00 2001 From: Willhong Date: Fri, 21 Aug 2026 10:04:55 +0900 Subject: [PATCH 3/7] fix(t3sidebar): address compact menu review --- .../plugins/t3sidebar/src/SubagentsChip.tsx | 130 ++++++++++++++---- 1 file changed, 105 insertions(+), 25 deletions(-) diff --git a/examples/plugins/t3sidebar/src/SubagentsChip.tsx b/examples/plugins/t3sidebar/src/SubagentsChip.tsx index 307158a3fc..b1ff6b7649 100644 --- a/examples/plugins/t3sidebar/src/SubagentsChip.tsx +++ b/examples/plugins/t3sidebar/src/SubagentsChip.tsx @@ -11,22 +11,42 @@ import { StatusGlyph } from "./StatusGlyph"; import { childrenOf, threadDisplayTitle } from "./inbox"; const MAX_DISCS = 3; -const CHILD_MENU_WIDTH = 320; const CHILD_MENU_VIEWPORT_GUTTER = 8; -function clampCompactMenuLeft(triggerRight: number, viewportWidth: number) { - const menuWidth = Math.min( - CHILD_MENU_WIDTH, - Math.max(0, viewportWidth - CHILD_MENU_VIEWPORT_GUTTER * 2), - ); - const minLeft = CHILD_MENU_VIEWPORT_GUTTER; +interface CompactViewportBounds { + left: number; + width: number; + safeAreaLeft: number; + safeAreaRight: 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, - viewportWidth - menuWidth - CHILD_MENU_VIEWPORT_GUTTER, + 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 * that opens the list of this thread's children. @@ -43,32 +63,64 @@ export function SubagentsChip({ 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 updateCompactMenuLeft = useCallback(() => { - const trigger = triggerRef.current; - if (!trigger) return; - setCompactMenuLeft( - clampCompactMenuLeft(trigger.getBoundingClientRect().right, innerWidth), + const updateCompactViewport = useCallback(() => { + const visualViewport = window.visualViewport; + const safeAreaProbe = safeAreaProbeRef.current; + const safeArea = safeAreaProbe ? getComputedStyle(safeAreaProbe) : null; + const next: CompactViewportBounds = { + left: visualViewport?.offsetLeft ?? 0, + width: visualViewport?.width ?? window.innerWidth, + safeAreaLeft: parsePixelValue(safeArea?.paddingLeft ?? ""), + safeAreaRight: parsePixelValue(safeArea?.paddingRight ?? ""), + }; + setCompactViewport((current) => + current?.left === next.left && + current.width === next.width && + current.safeAreaLeft === next.safeAreaLeft && + current.safeAreaRight === next.safeAreaRight + ? current + : next, ); }, []); useLayoutEffect(() => { if (!open || !isCompactViewport) return; - updateCompactMenuLeft(); + updateCompactViewport(); const viewport = window.visualViewport; - window.addEventListener("resize", updateCompactMenuLeft); - viewport?.addEventListener("resize", updateCompactMenuLeft); - viewport?.addEventListener("scroll", updateCompactMenuLeft); + window.addEventListener("resize", updateCompactViewport); + viewport?.addEventListener("resize", updateCompactViewport); + viewport?.addEventListener("scroll", updateCompactViewport); return () => { - window.removeEventListener("resize", updateCompactMenuLeft); - viewport?.removeEventListener("resize", updateCompactMenuLeft); - viewport?.removeEventListener("scroll", updateCompactMenuLeft); + window.removeEventListener("resize", updateCompactViewport); + viewport?.removeEventListener("resize", updateCompactViewport); + viewport?.removeEventListener("scroll", updateCompactViewport); }; - }, [isCompactViewport, open, updateCompactMenuLeft]); + }, [isCompactViewport, open, updateCompactViewport]); + + useLayoutEffect(() => { + if (!open || !isCompactViewport || !compactViewport) return; + const trigger = triggerRef.current; + const menu = menuRef.current; + if (!trigger || !menu) return; + const menuWidth = menu.getBoundingClientRect().width; + if (menuWidth <= 0) return; + setCompactMenuLeft( + clampCompactMenuLeft({ + triggerRight: trigger.getBoundingClientRect().right, + menuWidth, + viewport: compactViewport, + }), + ); + }, [compactViewport, isCompactViewport, open]); const children = childrenOf(threads, threadId); if (children.length === 0) return null; @@ -84,7 +136,7 @@ export function SubagentsChip({ aria-expanded={open} aria-label={`${children.length} child threads`} onClick={() => { - if (!open && isCompactViewport) updateCompactMenuLeft(); + if (!open && isCompactViewport) updateCompactViewport(); setOpen((value) => !value); }} className={cn( @@ -96,6 +148,18 @@ export function SubagentsChip({ {isCompactViewport ? null : {label}} + {isCompactViewport ? ( + + ) : null} {open ? ( <> {/* Click-away. Wide headers anchor the menu to this chip; compact @@ -106,15 +170,31 @@ export function SubagentsChip({ aria-hidden />
Children @@ -122,7 +202,7 @@ export function SubagentsChip({ {children.length}
-
    +
      {children.map((child) => (