Skip to content
80 changes: 51 additions & 29 deletions apps/app/src/components/sidebar/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
import { cn } from "@bb/shared-ui/lib/utils";
import { THREAD_JUMP_APP_COMMAND_IDS } from "@bb/domain";
import { Link, useNavigate } from "react-router-dom";
import { useAtomValue } from "jotai";
import { Icon } from "@bb/shared-ui/icon";
import { COARSE_POINTER_CHILD_ICON_BUTTON_CLASS } from "@bb/shared-ui/coarse-pointer-sizing";
import { OverflowFade } from "@/components/ui/overflow-fade.js";
Expand Down Expand Up @@ -66,6 +67,11 @@ import {
} from "@/components/commands/AppCommandProvider";
import { useRouteState } from "@/hooks/useRouteState";
import { usePluginNavPanelChrome } from "@/lib/plugin-nav-panel-chrome";
import { SidebarTopRegionCustomizeMenu } from "./SidebarTopRegionCustomizeMenu";
import {
sidebarTopRegionItemPreferencesAtom,
type SidebarTopRegionItemId,
} from "./sidebarTopRegionItemPreferences";

const BUG_REPORT_NEW_ISSUE_URL = "https://github.com/get-bb/bb/issues/new";
const SIDEBAR_FOOTER_ACTION_CLASS = cn(
Expand Down Expand Up @@ -179,6 +185,9 @@ export function AppSidebar({
);
const isAppCommandModifierHeld = useIsAppCommandModifierHeld();
const settingsShortcut = useAppCommandShortcut("settings.open");
const topRegionItemPreferences = useAtomValue(
sidebarTopRegionItemPreferencesAtom,
);
const pluginNavPanels = usePluginNavPanelChrome();
const automationsNavPanel = pluginNavPanels.find(
({ chrome }) => chrome.pluginId === AUTOMATIONS_PLUGIN_ID,
Expand Down Expand Up @@ -298,6 +307,33 @@ export function AppSidebar({
isCreatingProject={quickCreateProject.isCreating}
/>
);
const topRegionItemNodes: Record<SidebarTopRegionItemId, ReactNode | null> = {
"new-thread": (
<ProjectListActionButtons
splitEnabled
newThreadSplit={newThreadSplit}
onNewChat={handleNewChat}
onSplit={onSplit}
/>
),
extensions: toolsRoutePath ? (
<ExtensionsNavSidebarItem
routePath={toolsRoutePath}
onNavigate={closeOnMobile}
/>
) : null,
automations: automationsNavPanel ? (
<AutomationsNavSidebarItem
chrome={automationsNavPanel.chrome}
onNavigate={closeOnMobile}
/>
) : null,
};
const visibleTopRegionItems = topRegionItemPreferences.order.flatMap((id) => {
if (topRegionItemPreferences.hiddenIds.includes(id)) return [];
const node = topRegionItemNodes[id];
return node === null ? [] : [<Fragment key={id}>{node}</Fragment>];
});

const body = (
<>
Expand All @@ -323,42 +359,28 @@ export function AppSidebar({
usesDesktopChrome && MACOS_WINDOW_DRAG_CLASS,
)}
>
<SidebarHistoryNavigationControls
onNavigate={closeOnMobile}
<div
className={cn(
"group-data-[collapsible=icon]:hidden",
"flex items-center gap-1 group-data-[collapsible=icon]:hidden",
usesDesktopChrome && MACOS_CHROME_CONTROL_NO_DRAG_CLASS,
)}
/>
>
<SidebarHistoryNavigationControls onNavigate={closeOnMobile} />
<SidebarTopRegionCustomizeMenu />
</div>
</div>
) : null}
<SidebarTopLevelSections
sections={{
"new-thread-extensions": (
<div
data-testid="app-sidebar-primary-actions"
className="space-y-1 px-2 py-2 group-data-[collapsible=icon]:hidden"
>
<ProjectListActionButtons
splitEnabled
newThreadSplit={newThreadSplit}
onNewChat={handleNewChat}
onSplit={onSplit}
/>
{toolsRoutePath ? (
<ExtensionsNavSidebarItem
routePath={toolsRoutePath}
onNavigate={closeOnMobile}
/>
) : null}
{automationsNavPanel ? (
<AutomationsNavSidebarItem
chrome={automationsNavPanel.chrome}
onNavigate={closeOnMobile}
/>
) : null}
</div>
),
"new-thread-extensions":
visibleTopRegionItems.length > 0 ? (
<div
data-testid="app-sidebar-primary-actions"
className="space-y-1 px-2 py-2 group-data-[collapsible=icon]:hidden"
>
{visibleTopRegionItems}
</div>
) : null,
"plugin-pages": hasTraditionalPluginPanels ? (
<PluginNavSidebarItems onNavigate={closeOnMobile} splitEnabled />
) : null,
Expand Down
43 changes: 21 additions & 22 deletions apps/app/src/components/sidebar/ProjectList.modes.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ function getModeOrderProbeConfig(mode: SidebarOrganizationMode): {
switch (mode) {
case "project":
return { entitySectionIds: ["project:a"] };
case "chronological":
case "manual":
return { entitySectionIds: ["section:a"] };
case "machine":
return { entitySectionIds: [], hasThreadsSection: true };
Expand All @@ -82,23 +82,21 @@ function ModeOrderProbe({ mode }: { mode: SidebarOrganizationMode }) {

interface ActiveModeOrderProbeProps {
mode: SidebarOrganizationMode;
renderChronological?: () => ReactNode;
renderManual?: () => ReactNode;
renderMachine?: () => ReactNode;
renderProject?: () => ReactNode;
}

function ActiveModeOrderProbe({
mode,
renderChronological = () => (
<ModeOrderProbe key="chronological" mode="chronological" />
),
renderManual = () => <ModeOrderProbe key="manual" mode="manual" />,
renderMachine = () => <ModeOrderProbe key="machine" mode="machine" />,
renderProject = () => <ModeOrderProbe key="project" mode="project" />,
}: ActiveModeOrderProbeProps) {
return (
<ActiveSidebarModeSections
mode={mode}
renderChronological={renderChronological}
renderManual={renderManual}
renderMachine={renderMachine}
renderProject={renderProject}
/>
Expand Down Expand Up @@ -183,8 +181,8 @@ function MachineModeProbe({ threads = [] }: { threads?: ThreadListEntry[] }) {
collapsedThreadIds={new Set()}
collapsedEnvironmentIds={new Set()}
compareThreads={() => 0}
renderSectionDisplayOptions={() => null}
isSectionDisplayOptionsOpen={() => false}
displayOptions={<button aria-label="Display marker" />}
displayOptionsOpen={false}
onToggleCollapsed={handleToggleCollapsed}
onToggleThreadCollapsed={vi.fn()}
onToggleEnvironmentCollapsed={vi.fn()}
Expand All @@ -199,7 +197,7 @@ afterEach(() => {
});

describe("sidebar organization mode sections", () => {
it.each<SidebarOrganizationMode>(["project", "chronological", "machine"])(
it.each<SidebarOrganizationMode>(["project", "manual", "machine"])(
"keeps drafts above %s sections and archived rows trailing",
(mode) => {
const { container } = render(
Expand All @@ -208,7 +206,7 @@ describe("sidebar organization mode sections", () => {
activeModeSections={
<ActiveSidebarModeSections
mode={mode}
renderChronological={() => <div>Chronological</div>}
renderManual={() => <div>Manual</div>}
renderMachine={() => <div>Machine</div>}
renderProject={() => <div>Project</div>}
/>
Expand All @@ -219,8 +217,8 @@ describe("sidebar organization mode sections", () => {
);

const activeLabel =
mode === "chronological"
? "Chronological"
mode === "manual"
? "Manual"
: mode === "machine"
? "Machine"
: "Project";
Expand All @@ -233,17 +231,15 @@ describe("sidebar organization mode sections", () => {
store.set(sidebarSectionOrderAtom, ["threads", "project:a", "pinned"]);
store.set(sidebarManualSectionOrderAtom, ["section:stale"]);
store.set(sidebarMachineSectionOrderAtom, ["machine:stale"]);
const renderChronological = vi.fn(() => (
<ModeOrderProbe mode="chronological" />
));
const renderManual = vi.fn(() => <ModeOrderProbe mode="manual" />);
const renderMachine = vi.fn(() => <MachineModeProbe />);
const renderProject = vi.fn(() => <ModeOrderProbe mode="project" />);

render(
<JotaiProvider store={store}>
<ActiveModeOrderProbe
mode="project"
renderChronological={renderChronological}
renderManual={renderManual}
renderMachine={renderMachine}
renderProject={renderProject}
/>
Expand All @@ -252,7 +248,7 @@ describe("sidebar organization mode sections", () => {

await screen.findByTestId("project-order");
expect(renderProject).toHaveBeenCalledOnce();
expect(renderChronological).not.toHaveBeenCalled();
expect(renderManual).not.toHaveBeenCalled();
expect(renderMachine).not.toHaveBeenCalled();
expect(mockUseHosts).not.toHaveBeenCalled();
expect(mockBuildMachineThreadGroups).not.toHaveBeenCalled();
Expand All @@ -278,8 +274,8 @@ describe("sidebar organization mode sections", () => {
);

expect(await screen.findByTestId("project-order")).not.toBeNull();
act(() => store.set(sidebarOrganizationModeAtom, "chronological"));
expect(await screen.findByTestId("chronological-order")).not.toBeNull();
act(() => store.set(sidebarOrganizationModeAtom, "manual"));
expect(await screen.findByTestId("manual-order")).not.toBeNull();
act(() => store.set(sidebarOrganizationModeAtom, "machine"));
expect(await screen.findByTestId("machine-order")).not.toBeNull();
act(() => store.set(sidebarOrganizationModeAtom, "project"));
Expand All @@ -292,7 +288,7 @@ describe("sidebar organization mode sections", () => {
});
});

it("collapses and expands empty-machine Threads", () => {
it("uses a Machines fallback and one global display control when empty", () => {
const store = createStore();
store.set(sidebarMachineSectionOrderAtom, ["threads"]);
store.set(collapsedSidebarSectionIdsAtom, []);
Expand All @@ -304,13 +300,16 @@ describe("sidebar organization mode sections", () => {
);

expect(screen.getByText("No threads")).not.toBeNull();
expect(
screen.getAllByRole("button", { name: "Display marker" }),
).toHaveLength(1);
fireEvent.click(
screen.getByRole("button", { name: "Collapse Threads section" }),
screen.getByRole("button", { name: "Collapse Machines section" }),
);
expect(screen.queryByText("No threads")).toBeNull();

fireEvent.click(
screen.getByRole("button", { name: "Expand Threads section" }),
screen.getByRole("button", { name: "Expand Machines section" }),
);
expect(screen.getByText("No threads")).not.toBeNull();
expect(mockBuildMachineThreadGroups).toHaveBeenCalledWith([], []);
Expand Down
Loading
Loading