Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
358 changes: 312 additions & 46 deletions apps/app/src/components/secondary-panel/SidebarSplitContainer.test.tsx

Large diffs are not rendered by default.

324 changes: 226 additions & 98 deletions apps/app/src/components/secondary-panel/SidebarSplitContainer.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { act, cleanup, fireEvent, render, screen } from "@testing-library/react";
import { renderToStaticMarkup } from "react-dom/server";
import { MemoryRouter } from "react-router-dom";
import type { Environment, Thread } from "@bb/domain";
import { TooltipProvider } from "@bb/shared-ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { EnvironmentRow, ParentSelectorRow } from "./ThreadMetadataContent";
import {
EnvironmentRow,
ParentSelectorRow,
ThreadMetadataCard,
} from "./ThreadMetadataContent";
import { parentThreads } from "./ThreadMetadataContent.fixtures";

const localHost = { locality: "local", identity: null } as const;
Expand Down Expand Up @@ -73,7 +77,37 @@ function renderEnvironmentRow(environment: Environment): string {
);
}

afterEach(cleanup);
afterEach(() => {
cleanup();
vi.useRealTimers();
});

describe("ThreadMetadataCard", () => {
it("shows its scrollbar only during active scrolling", () => {
vi.useFakeTimers();
const { container } = render(
<ThreadMetadataCard>
<div>Thread information</div>
</ThreadMetadataCard>,
);
const scrollArea = container.querySelector("dl");
if (!(scrollArea instanceof HTMLElement)) {
throw new Error("missing info scroll area");
}

expect(scrollArea.classList).toContain("transient-scrollbar");
expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false);

fireEvent.scroll(scrollArea);
expect(scrollArea.dataset.scrollbarScrolling).toBe("true");

act(() => vi.advanceTimersByTime(599));
expect(scrollArea.dataset.scrollbarScrolling).toBe("true");

act(() => vi.advanceTimersByTime(1));
expect(scrollArea.hasAttribute("data-scrollbar-scrolling")).toBe(false);
});
});

describe("EnvironmentRow", () => {
it("shows the create-thread action for a provisioned worktree", () => {
Expand Down
39 changes: 37 additions & 2 deletions apps/app/src/components/secondary-panel/ThreadMetadataContent.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { useCallback, useMemo, type ReactNode } from "react";
import {
useCallback,
useEffect,
useMemo,
useRef,
type ReactNode,
type UIEvent,
} from "react";
import { ThreadStorageBrowser } from "./ThreadStorageBrowser";
import type { ThreadStorageBrowserController } from "./useThreadStorageBrowser";
import { Link } from "react-router-dom";
Expand Down Expand Up @@ -968,6 +975,8 @@ interface DetailCardWrapperProps {
children: ReactNode;
}

const INFO_SCROLLBAR_IDLE_DELAY_MS = 600;

/**
* Shared DetailCard styling used by ThreadMetadataContent and the per-row
* stories so a single row in isolation looks the same as it does inside the
Expand All @@ -981,10 +990,36 @@ interface DetailCardWrapperProps {
* card itself only scrolls once those minimums no longer fit.
*/
export function ThreadMetadataCard({ children }: DetailCardWrapperProps) {
const scrollbarIdleTimeoutRef = useRef<number | null>(null);

useEffect(
() => () => {
if (scrollbarIdleTimeoutRef.current !== null) {
window.clearTimeout(scrollbarIdleTimeoutRef.current);
}
},
[],
);

const handleScroll = useCallback((event: UIEvent<HTMLDListElement>) => {
const scrollArea = event.currentTarget;
if (scrollArea.dataset.scrollbarScrolling !== "true") {
scrollArea.dataset.scrollbarScrolling = "true";
}
if (scrollbarIdleTimeoutRef.current !== null) {
window.clearTimeout(scrollbarIdleTimeoutRef.current);
}
scrollbarIdleTimeoutRef.current = window.setTimeout(() => {
scrollbarIdleTimeoutRef.current = null;
scrollArea.removeAttribute("data-scrollbar-scrolling");
}, INFO_SCROLLBAR_IDLE_DELAY_MS);
}, []);

return (
<DetailCard
appearance="flat"
className="min-h-0 flex-1 gap-1.5 overflow-x-hidden overflow-y-auto px-4 py-3"
className="transient-scrollbar min-h-0 flex-1 gap-1.5 overflow-x-hidden overflow-y-auto px-4 py-3"
onScroll={handleScroll}
>
{children}
</DetailCard>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -724,6 +724,7 @@ describe("ThreadSecondaryPanel full-screen control", () => {

const control = view.getByRole("button", { name: "Exit Full Screen" });
expect(control.getAttribute("aria-pressed")).toBe("true");
expect(document.querySelector("aside")?.style.width).toBe("100%");

fireEvent.click(control);
expect(onToggleConversationCollapse).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -771,7 +772,15 @@ describe("ThreadSecondaryPanel full-screen control", () => {
</Wrapper>,
);

const control = screen.getByRole("button", { name: "Full Screen" });
expect(screen.getByRole("button", { name: "Open new tab" })).not.toBeNull();
expect(
screen.queryByRole("button", { name: "Open new tab in this pane" }),
).toBeNull();
expect(
document.querySelectorAll("[data-new-tab-control-reserved]"),
).toHaveLength(0);

const control = screen.getByRole("button", { name: "Maximize pane" });
fireEvent.focus(control);
expect(
screen.getByRole("menu", { name: "Pane arrangement" }),
Expand Down Expand Up @@ -803,16 +812,55 @@ describe("ThreadSecondaryPanel full-screen control", () => {
pane.querySelector('[data-testid="thread-secondary-panel-top-chrome"]'),
),
).toBe(true);
expect(
screen.getAllByRole("button", { name: "Maximize pane" }),
).toHaveLength(2);
expect(
screen.getAllByRole("button", { name: "Remove split" }),
).toHaveLength(2);
expect(
screen.getAllByRole("button", { name: "Hide right panel" }),
).toHaveLength(1);
expect(document.querySelectorAll("header")).toHaveLength(0);
const newTabControls = screen.getAllByRole("button", {
name: "Open new tab",
name: "Open new tab in this pane",
});
expect(newTabControls).toHaveLength(1);
fireEvent.click(newTabControls[0] as HTMLElement);
expect(
document.querySelectorAll("[data-new-tab-control-reserved]"),
).toHaveLength(1);
const initiallyFocusedPane = panes.find(
(pane) => pane.dataset.focused === "true",
);
const initiallyInactivePane = panes.find(
(pane) => pane.dataset.focused === "false",
);
expect(
initiallyFocusedPane?.querySelector(
'button[aria-label^="Open new tab in this pane"]',
),
).not.toBeNull();
expect(
initiallyInactivePane?.querySelector("[data-new-tab-control-reserved]"),
).not.toBeNull();

fireEvent.pointerDown(initiallyInactivePane as HTMLElement);
const focusedPaneNewTabControl = screen.getByRole("button", {
name: "Open new tab in this pane",
});
expect(
focusedPaneNewTabControl.closest("[data-split-pane-id]"),
).toBe(initiallyInactivePane);
expect(
initiallyFocusedPane?.querySelector("[data-new-tab-control-reserved]"),
).not.toBeNull();
fireEvent.click(focusedPaneNewTabControl);
expect(onOpenNewTab).toHaveBeenCalledTimes(1);
expect(initiallyInactivePane?.dataset.focused).toBe("true");
expect(initiallyFocusedPane?.dataset.focused).toBe("false");
});

it("keeps pane-local tab rows and one restore control in a stacked split", () => {
it("maximizes one stacked pane while keeping both tab rows mounted", () => {
const { wrapper: Wrapper } = createQueryClientTestHarness();
const fileTab = createWorkspaceFilePreviewFixedPanelTab({
environmentId: "env-test",
Expand Down Expand Up @@ -871,7 +919,7 @@ describe("ThreadSecondaryPanel full-screen control", () => {
);

const restoreControls = screen.getAllByRole("button", {
name: "Exit Full Screen",
name: "Restore split",
});
expect(restoreControls).toHaveLength(1);
const panes = Array.from(
Expand All @@ -895,23 +943,30 @@ describe("ThreadSecondaryPanel full-screen control", () => {
).length,
),
).toEqual([1, 1]);
expect(
screen
.getByRole("separator", {
name: "Resize stacked right panel panes",
})
.getAttribute("aria-orientation"),
).toBe("horizontal");
const separator = document.querySelector<HTMLElement>(
'[aria-label="Resize stacked right panel panes"]',
);
expect(separator?.getAttribute("aria-orientation")).toBe("horizontal");
expect(separator?.getAttribute("aria-hidden")).toBe("true");
expect(separator?.className).toContain("invisible");
expect(
panes.map(
(pane) =>
pane.querySelectorAll('[aria-label="Exit Full Screen"]').length,
(pane) => pane.querySelectorAll('[aria-label="Restore split"]').length,
),
).toEqual([1, 0]);
).toEqual([0, 1]);
expect(panes[0]?.getAttribute("aria-hidden")).toBe("true");
expect(panes[0]?.style.contentVisibility).toBe("hidden");
expect(panes[1]?.dataset.maximized).toBe("true");
const restoreControl = restoreControls[0];
if (restoreControl === undefined)
throw new Error("Missing restore control");
fireEvent.click(restoreControl);
expect(onToggleConversationCollapse).toHaveBeenCalledTimes(1);
expect(
panes.every((pane) => pane.getAttribute("aria-hidden") === null),
).toBe(true);
expect(
document.querySelector('[data-split-pane-id][data-maximized="true"]'),
).toBeNull();
});
});
Loading
Loading