diff --git a/apps/app/src/components/plugin/PluginsOverview.test.tsx b/apps/app/src/components/plugin/PluginsOverview.test.tsx
index 9d3a09208e..ac87f42445 100644
--- a/apps/app/src/components/plugin/PluginsOverview.test.tsx
+++ b/apps/app/src/components/plugin/PluginsOverview.test.tsx
@@ -270,6 +270,9 @@ describe("PluginsOverview", () => {
});
expect(filters.className).toContain("py-2");
expect(filters.className).toContain("gap-2");
+ expect(all.className).toContain("cursor-pointer");
+ expect(all.className).toContain("hover:border-foreground/20");
+ expect(all.className).toContain("hover:shadow-xs");
expect(all.className).toContain("data-[state=on]:bg-secondary/70");
expect(all.className).not.toContain("data-[state=on]:bg-state-active");
expect(screen.getByRole("tab", { name: "Browse" }).className).toContain(
@@ -549,8 +552,12 @@ describe("PluginsOverview", () => {
expect(officialPills[0]?.parentElement?.className).toContain("px-2");
expect(officialPills[0]?.parentElement?.className).toContain("py-1");
- fireEvent.pointerDown(screen.getByRole("button", { name: "Sort" }));
- fireEvent.click(screen.getByRole("menuitem", { name: "Plugin name" }));
+ const sortTrigger = screen.getByRole("button", {
+ name: "Sort: Plugin name, ascending",
+ });
+ expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy();
+ fireEvent.pointerDown(sortTrigger);
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" }));
expect(
[...document.querySelectorAll('[data-testid^="plugin-row-"]')].map(
(row) => row.getAttribute("data-testid"),
@@ -563,9 +570,12 @@ describe("PluginsOverview", () => {
"plugin-row-inactive-local",
]);
- fireEvent.keyDown(screen.getByRole("menu", { name: "Sort" }), {
- key: "Escape",
- });
+ fireEvent.keyDown(
+ screen.getByRole("menu", {
+ name: "Sort: Plugin name, descending",
+ }),
+ { key: "Escape" },
+ );
fireEvent.click(screen.getByRole("tab", { name: "Browse" }));
await screen.findByText("GitHub");
fireEvent.click(screen.getByRole("tab", { name: "Installed, 5 plugins" }));
diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx
index 97cec194fd..c178395739 100644
--- a/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx
+++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.test.tsx
@@ -125,8 +125,12 @@ describe("BrowsePluginsTab", () => {
"Open Zulu details",
]);
- fireEvent.pointerDown(screen.getByRole("button", { name: "Sort" }));
- fireEvent.click(screen.getByRole("menuitem", { name: "Plugin name" }));
+ const sortTrigger = screen.getByRole("button", {
+ name: "Sort: Plugin name, ascending",
+ });
+ expect(sortTrigger.querySelector('[data-icon="ArrowUpDown"]')).toBeTruthy();
+ fireEvent.pointerDown(sortTrigger);
+ fireEvent.click(screen.getByRole("menuitemradio", { name: "Plugin name" }));
expect(cardOrder()).toEqual([
"Open Zulu details",
"Open Middle details",
@@ -239,6 +243,15 @@ describe("BrowsePluginsTab", () => {
.closest(".group");
expect(githubCard?.className).toContain("min-h-20");
expect(githubCard?.className).toContain("p-2.5");
+ const memoryDescriptions = screen.getAllByText(MEMORY_ENTRY.description);
+ const githubDescription = screen.getByText(GITHUB_ENTRY.description);
+ for (const memoryDescription of memoryDescriptions) {
+ expect(memoryDescription.className).toContain("min-h-[2lh]");
+ expect(memoryDescription.parentElement?.className).toContain(
+ "line-clamp-2",
+ );
+ }
+ expect(githubDescription.className).toContain("min-h-[2lh]");
expect(
screen.getByRole("radio", { name: "Context & knowledge" }),
).toBeTruthy();
@@ -260,6 +273,7 @@ describe("BrowsePluginsTab", () => {
const install = screen.getByRole("button", { name: "Install Memory" });
expect(install.className).toContain("w-7");
+ expect(install.querySelector('[data-icon="Download"]')).not.toBeNull();
fireEvent.pointerMove(install);
expect((await screen.findByRole("tooltip")).textContent).toBe(
"Install Memory",
@@ -348,8 +362,28 @@ describe("BrowsePluginsTab", () => {
expect((await screen.findByRole("tooltip")).textContent).toBe(
"Uninstall Memory",
);
- expect(document.querySelector('[data-icon="Check"]')).not.toBeNull();
+ expect(installed.querySelector('[data-icon="Download"]')).not.toBeNull();
+ expect(installed.querySelector('[data-icon="Check"]')).toBeNull();
+ expect(installed.className).toContain("border-success/40");
+ expect(installed.className).toContain("bg-success/15");
+ expect(installed.className).toContain(
+ "text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]",
+ );
+ expect(installed.className).not.toContain("text-success-foreground");
+ expect(installed.className).toContain(
+ "hover:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]",
+ );
+ expect(installed.className).toContain(
+ "focus-visible:text-[color:color-mix(in_oklab,var(--success)_72%,var(--ink))]",
+ );
+ expect(installed.className).not.toContain("hover:text-foreground");
+ expect(installed.className).toContain("hover:bg-success/25");
expect(screen.queryByRole("button", { name: "Install" })).toBeNull();
+ fireEvent.click(installed);
+ expect(
+ screen.getByRole("heading", { name: "Uninstall Memory?" }),
+ ).toBeTruthy();
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
fireEvent.click(
screen.getByRole("button", { name: "Open Memory details" }),
diff --git a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
index 4ac279fb6f..3de704b235 100644
--- a/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
+++ b/apps/app/src/components/plugin/management/BrowsePluginsTab.tsx
@@ -6,7 +6,6 @@ import {
ResourceBrowseGrid,
ResourceCollectionViewport,
ResourceInstallControl,
- ResourceInstalledControl,
ResourceListState,
ResourceSortMenu,
ResourceToolbar,
@@ -184,17 +183,21 @@ function BrowseCard({
);
const description =
entry.description.length > 0 ? entry.description : undefined;
+ const descriptionArea = (
+ {description}
+ );
const byline =
!entry.compatible && entry.incompatibleReason !== null ? (
{entry.incompatibleReason}
) : undefined;
const headerAction =
installedPluginId !== null ? (
- setConfirmingUninstall(true)}
/>
) : (
@@ -219,7 +222,7 @@ function BrowseCard({
className="min-h-20 gap-x-2 gap-y-1.5 p-2.5"
leading={leading}
title={entry.displayName}
- description={description}
+ description={descriptionArea}
byline={byline}
headerAction={headerAction}
openLabel={`Open ${entry.displayName} details`}
diff --git a/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx b/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx
index 2f4e6b3401..76dc80891f 100644
--- a/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx
+++ b/apps/app/src/components/plugin/management/PluginCategoryFilterPills.tsx
@@ -1,6 +1,8 @@
import { ToggleGroup, ToggleGroupItem } from "@bb/shared-ui/toggle-group";
const ALL_CATEGORIES = "all";
+const CATEGORY_FILTER_CLASS =
+ "h-7 min-w-0 cursor-pointer rounded-full border border-border bg-transparent px-3 text-xs text-muted-foreground shadow-none transition-[background-color,border-color,box-shadow,color] duration-150 hover:border-foreground/20 hover:bg-secondary/50 hover:text-foreground hover:shadow-xs data-[state=on]:border-transparent data-[state=on]:bg-secondary/70 data-[state=on]:text-secondary-foreground";
export function PluginCategoryFilterPills({
categories,
@@ -27,7 +29,7 @@ export function PluginCategoryFilterPills({
All
@@ -35,7 +37,7 @@ export function PluginCategoryFilterPills({
{category}
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts
index 07c1b02a16..aab6f57837 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.test.ts
@@ -1,29 +1,90 @@
+// @vitest-environment jsdom
+
+import { act, cleanup, render } from "@testing-library/react";
+import { createElement } from "react";
+import { afterEach, vi } from "vitest";
import { describe, expect, it } from "vitest";
import {
- getTabStripChevronEdgeClass,
- getTabStripChevronVisibilityClass,
+ SecondaryPanelTabStrip,
SECONDARY_PANEL_TAB_STRIP_FADE_TONE,
} from "./SecondaryPanelTabStrip";
+afterEach(() => {
+ cleanup();
+ vi.unstubAllGlobals();
+});
+
describe("secondary panel tab-strip edge fades", () => {
- it("uses one opaque themed edge fade and no second caret gradient", () => {
+ it("uses the themed edge fade without overlay scroll controls", () => {
expect(SECONDARY_PANEL_TAB_STRIP_FADE_TONE).toBe("sidebar");
- expect(getTabStripChevronEdgeClass("left")).toBe(
- "left-0 justify-start",
- );
- expect(getTabStripChevronEdgeClass("right")).toBe(
- "right-0 justify-end",
- );
});
- it("keeps an available scroll control visible without requiring hover", () => {
- const visibleClass = getTabStripChevronVisibilityClass(true);
+ it("observes the intrinsic tab row so async title changes refresh overflow", () => {
+ const observed: Element[] = [];
+ let resizeCallback: ResizeObserverCallback | undefined;
+ vi.stubGlobal(
+ "ResizeObserver",
+ class {
+ constructor(callback: ResizeObserverCallback) {
+ resizeCallback = callback;
+ }
+ observe(element: Element) {
+ observed.push(element);
+ }
+ disconnect() {}
+ },
+ );
+
+ const { container } = render(
+ createElement(SecondaryPanelTabStrip, {
+ fileTabs: [
+ {
+ id: "browser",
+ filename: "Browser",
+ isActive: true,
+ isPinned: false,
+ leadingVisual: null,
+ statusLabel: null,
+ onSelect: vi.fn(),
+ onClose: vi.fn(),
+ },
+ ],
+ onReorderTab: vi.fn(),
+ usesDesktopChrome: false,
+ }),
+ );
- expect(visibleClass).toContain("pointer-events-auto");
- expect(visibleClass).toContain("opacity-100");
- expect(visibleClass).not.toContain("hover:");
- expect(getTabStripChevronVisibilityClass(false)).toBe(
- "pointer-events-none opacity-0",
+ const viewport = container.querySelector(".no-scrollbar");
+ const content = container.querySelector(
+ "[data-secondary-panel-tab-content]",
);
+ expect(content).not.toBeNull();
+ expect(observed).toContain(viewport);
+ expect(observed).toContain(content);
+ expect(resizeCallback).toBeDefined();
+ expect(container.querySelectorAll("[data-overflow-fade]")).toHaveLength(2);
+ expect(
+ container
+ .querySelector("[data-overflow-fade='left']")
+ ?.classList.contains("w-6"),
+ ).toBe(true);
+ expect(
+ container.querySelector('[aria-label="Scroll tabs left"]'),
+ ).toBeNull();
+ expect(
+ container.querySelector('[aria-label="Scroll tabs right"]'),
+ ).toBeNull();
+
+ const rightFade = container.querySelector("[data-overflow-fade='right']");
+ expect(rightFade?.classList.contains("opacity-0")).toBe(true);
+ Object.defineProperties(viewport!, {
+ clientWidth: { configurable: true, value: 120 },
+ scrollWidth: { configurable: true, value: 240 },
+ scrollLeft: { configurable: true, value: 0, writable: true },
+ });
+ act(() => {
+ resizeCallback?.([], {} as ResizeObserver);
+ });
+ expect(rightFade?.classList.contains("opacity-100")).toBe(true);
});
});
diff --git a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx
index c7707e8293..335bdb2cbd 100644
--- a/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx
+++ b/apps/app/src/components/secondary-panel/SecondaryPanelTabStrip.tsx
@@ -27,9 +27,6 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
-import { Button } from "@bb/shared-ui/button";
-import { COARSE_POINTER_COMPACT_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
-import { Icon } from "@bb/shared-ui/icon";
import {
OverflowFade,
type OverflowFadeTone,
@@ -37,40 +34,18 @@ import {
import { TabPill } from "@/components/ui/tab-pill";
import { useDragClickSuppression } from "@/components/ui/use-drag-click-suppression";
import { cn } from "@bb/shared-ui/lib/utils";
-import {
- MACOS_APP_REGION_NO_DRAG_CLASS,
- MACOS_WINDOW_NO_DRAG_CLASS,
-} from "@/lib/bb-desktop";
+import { MACOS_WINDOW_NO_DRAG_CLASS } from "@/lib/bb-desktop";
import type {
SecondaryPanelFileTab,
SecondaryPanelTabReorderHandler,
} from "./secondaryPanelFileTab";
export type { SecondaryPanelFileTab } from "./secondaryPanelFileTab";
-// How far a chevron click nudges the strip, in CSS pixels. Roughly one wide
-// file tab so a click reveals the next tab without overshooting.
-const CHEVRON_SCROLL_STEP_PX = 140;
-
-// Slack so sub-pixel scroll offsets don't leave a fade/chevron stuck on at a
-// hard edge.
+// Slack so sub-pixel scroll offsets don't leave a fade stuck on at a hard edge.
const EDGE_EPSILON_PX = 1;
export const SECONDARY_PANEL_TAB_STRIP_FADE_TONE: OverflowFadeTone = "sidebar";
-export function getTabStripChevronEdgeClass(
- direction: "left" | "right",
-): string {
- return direction === "left"
- ? "left-0 justify-start"
- : "right-0 justify-end";
-}
-
-export function getTabStripChevronVisibilityClass(canScroll: boolean): string {
- return canScroll
- ? "pointer-events-auto opacity-100"
- : "pointer-events-none opacity-0";
-}
-
interface TabStripOverflowState {
/** Scrolled away from the left edge (content hidden to the left). */
canScrollLeft: boolean;
@@ -103,8 +78,8 @@ interface SortableFileTabProps {
*
* Only the file tabs scroll; the leading Info/Diff controls and trailing
* new-tab/panel controls stay anchored outside this component. Edge
- * fades and scroll chevrons appear only on a side that has more tabs, and the
- * active tab is auto-scrolled into view on mount and whenever it changes
+ * fades appear only on a side that has more tabs, and the active tab is
+ * auto-scrolled into view on mount and whenever it changes
* (covering pointer, keyboard, and programmatic selection).
*/
export function SecondaryPanelTabStrip({
@@ -114,6 +89,7 @@ export function SecondaryPanelTabStrip({
activeTreatment = "fill",
}: SecondaryPanelTabStripProps) {
const viewportRef = useRef(null);
+ const contentRef = useRef(null);
const activeTabRef = useRef(null);
const [overflow, setOverflow] = useState(
INITIAL_OVERFLOW_STATE,
@@ -178,9 +154,9 @@ export function SecondaryPanelTabStrip({
applyEdgeFlags();
}, [applyEdgeFlags]);
- // Track the viewport's own scrolling and resizing. The ResizeObserver fires
- // once on observe (seeding the initial capacity + flags) and on every resize
- // (including the panel's drag-resize, which changes clientWidth).
+ // Track the viewport's own scrolling and both dimensions that determine its
+ // capacity. The content row can change intrinsic width without the viewport
+ // resizing (for example, when an async browser title replaces "Browser").
useEffect(() => {
const viewport = viewportRef.current;
if (viewport === null) {
@@ -200,6 +176,9 @@ export function SecondaryPanelTabStrip({
viewport.addEventListener("scroll", handleScroll, { passive: true });
const resizeObserver = new ResizeObserver(measureCapacity);
resizeObserver.observe(viewport);
+ if (contentRef.current !== null) {
+ resizeObserver.observe(contentRef.current);
+ }
return () => {
viewport.removeEventListener("scroll", handleScroll);
resizeObserver.disconnect();
@@ -284,12 +263,6 @@ export function SecondaryPanelTabStrip({
};
}, []);
- const scrollByStep = (direction: -1 | 1) => {
- viewportRef.current?.scrollBy({
- left: direction * CHEVRON_SCROLL_STEP_PX,
- behavior: "smooth",
- });
- };
const handleDragStart = useCallback(
(event: DragStartEvent) => {
setDraggingTabId(String(event.active.id));
@@ -329,12 +302,9 @@ export function SecondaryPanelTabStrip({
);
const noDragClass = usesDesktopChrome ? MACOS_WINDOW_NO_DRAG_CLASS : null;
- const chevronNoDragClass = usesDesktopChrome
- ? MACOS_APP_REGION_NO_DRAG_CLASS
- : null;
// Memoize the sortable tab tree so the overflow-flag state — which flips every
// time you reach a scroll edge, i.e. constantly at narrow widths — re-renders
- // only the edge fades/chevrons, never the tabs. Without this, each edge
+ // only the edge fades, never the tabs. Without this, each edge
// crossing reconciles the whole list and re-runs useSortable for every tab,
// which is what kept narrow-width scrolling stuttery.
const dndTabs = useMemo(
@@ -391,22 +361,22 @@ export function SecondaryPanelTabStrip({
return (
// Hugs its tabs (no `flex-1`) and shrinks (`min-w-0`) to scroll them under
- // the edge fades/chevrons when they overflow. The New Tab button follows this
+ // the edge fades when they overflow. The New Tab button follows this
// viewport as an anchored sibling, so it stays visible at the trailing edge
// while overflowing tabs scroll beneath the fades.
- {/* The single surface-colored fade stays opaque beneath the caret at the
- outer edge while progressively obscuring only the tab content moving
- behind it. The caret itself deliberately adds no second gradient: a
- stacked gradient turns this transition into a mismatched solid tile. */}
+ {/* Keep the overflow cue stationary. Overlay scroll buttons used to
+ animate above partially visible tabs, making the tab text and selected
+ fill look clipped while the strip moved. Native wheel, trackpad, and
+ active-tab scrolling already provide the interaction. */}
@@ -414,7 +384,7 @@ export function SecondaryPanelTabStrip({
placement="right"
tone={SECONDARY_PANEL_TAB_STRIP_FADE_TONE}
className={cn(
- "z-10 transition-opacity",
+ "z-10",
overflow.canScrollRight ? "opacity-100" : "opacity-0",
)}
/>
@@ -425,24 +395,17 @@ export function SecondaryPanelTabStrip({
// (see the wheel handler), and CSS smooth-scroll would turn each wheel
// notch into its own ~150ms animation — the strip advances, sits frozen
// between notches, then jumps. Letting it track 1:1 matches native
- // horizontal trackpad scrolling. The chevron buttons opt back into smooth
- // per-call via `scrollBy({ behavior: "smooth" })`.
- className="no-scrollbar flex min-w-0 items-center gap-1 overflow-x-auto overflow-y-hidden"
+ // horizontal trackpad scrolling.
+ className="no-scrollbar min-w-0 overflow-x-auto overflow-y-hidden"
>
- {dndTabs}
+
{content.kind === "new-thread" ? (
) : (
diff --git a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
index 1b98ec8ec4..e3e75a7b7c 100644
--- a/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
+++ b/apps/app/src/views/thread-detail/SplitWorkspaceSecondaryPanelHost.tsx
@@ -10,7 +10,6 @@ import { useAtomValue } from "jotai";
import {
Panel,
PanelGroup,
- PanelResizeHandle,
type ImperativePanelGroupHandle,
} from "react-resizable-panels";
import { Button } from "@bb/shared-ui/button";
@@ -23,17 +22,12 @@ 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";
@@ -147,9 +141,8 @@ export function SplitWorkspaceSecondaryPanelHost({
panelWidthPercent,
]);
- // Panes without a secondary panel (plugin panes) disable this control. The
- // remembered window visibility is intentionally left unchanged so returning
- // to a thread restores the panel the user had open.
+ // The remembered window visibility is intentionally left unchanged while a
+ // plugin pane is focused, so returning to a thread restores its app panel.
const toggleWindowPanel = () => {
model?.onToggle();
};
@@ -168,6 +161,16 @@ export function SplitWorkspaceSecondaryPanelHost({
[isOpen, isPaneMaximized],
);
+ 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
@@ -182,42 +185,32 @@ export function SplitWorkspaceSecondaryPanelHost({
className={cn(
"absolute right-2.5 top-2.5 z-40",
(isOpen || isPaneMaximized) && "hidden",
- // This overlay already owns positioning and stacking. Use only the
- // raw app-region token: MACOS_WINDOW_NO_DRAG_CLASS adds `relative
- // z-50`, which tailwind-merge would resolve against `absolute` and
- // move the control back into document flow.
+ // This overlay already owns positioning and stacking. Use only
+ // the raw app-region token: MACOS_WINDOW_NO_DRAG_CLASS adds
+ // `relative z-50`, which tailwind-merge would resolve against
+ // `absolute` and move the control back into document flow.
MACOS_APP_REGION_NO_DRAG_CLASS,
)}
>
- {model === null ? (
- <>
- {/* Keep a collapsed second panel registered with the group while
- the focused plugin pane has no secondary-panel model. */}
-
-
- >
- ) : (
-
- {model.panel}
-
- )}
+
+ {model.panel}
+