diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index e74323feba..c7e88d76ec 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx @@ -31,6 +31,7 @@ vi.mock("@/lib/sdk", () => ({ update: vi.fn(), }, system: { config: vi.fn() }, + threadSections: { experimental_listWithIcons: vi.fn() }, }, })); @@ -143,6 +144,9 @@ async function openHostMenu(hostName: string): Promise { beforeEach(() => { hostDaemon.localDaemonHostId = "host_primary"; hostDaemon.platform = "darwin"; + vi.mocked(sdk.threadSections.experimental_listWithIcons).mockResolvedValue( + [], + ); }); afterEach(() => { diff --git a/apps/app/src/components/sidebar/PluginSidebarSectionActions.tsx b/apps/app/src/components/sidebar/PluginSidebarSectionActions.tsx new file mode 100644 index 0000000000..cb6457c93a --- /dev/null +++ b/apps/app/src/components/sidebar/PluginSidebarSectionActions.tsx @@ -0,0 +1,244 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { + PluginSidebarSectionActionContext, + PluginSidebarSectionActionPresentation, +} from "@get-bb/plugin-sdk"; +import { Button } from "@bb/shared-ui/button"; +import { DropdownMenuItem } from "@bb/shared-ui/dropdown-menu"; +import { Icon, isIconName, type IconName } from "@bb/shared-ui/icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; +import { + COARSE_POINTER_ICON_SIZE_CLASS, + COARSE_POINTER_ROW_ACTION_SIZE_CLASS, +} from "@bb/shared-ui/coarse-pointer-sizing"; +import { cn } from "@bb/shared-ui/lib/utils"; +import { + usePluginSlots, + type PluginSidebarSectionActionSlot, +} from "@/lib/plugin-slots"; + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export interface ResolvedPluginSidebarSectionAction { + key: string; + icon: IconName; + presentation: PluginSidebarSectionActionPresentation; + context: PluginSidebarSectionActionContext; + slot: PluginSidebarSectionActionSlot; +} + +export interface PluginSidebarSectionActionResolution { + hasPressed: boolean; + hasPressedOverflow: boolean; + inlineAction: ResolvedPluginSidebarSectionAction | null; + overflowActions: readonly ResolvedPluginSidebarSectionAction[]; + run(action: ResolvedPluginSidebarSectionAction): void; +} + +function requiresPrimaryModifier( + action: ResolvedPluginSidebarSectionAction, +): boolean { + return action.slot.experimental_requiresPrimaryModifier === true; +} + +function canActivateWithModifiers( + action: ResolvedPluginSidebarSectionAction, + modifiers: { ctrlKey: boolean; metaKey: boolean }, +): boolean { + return ( + !requiresPrimaryModifier(action) || modifiers.metaKey || modifiers.ctrlKey + ); +} + +function activationLabel(action: ResolvedPluginSidebarSectionAction): string { + return requiresPrimaryModifier(action) + ? `${action.presentation.title} (Command/Ctrl-click)` + : action.presentation.title; +} + +function resolvePresentation( + slot: PluginSidebarSectionActionSlot, + context: PluginSidebarSectionActionContext, +): ResolvedPluginSidebarSectionAction | null { + let presentation: PluginSidebarSectionActionPresentation | null; + try { + presentation = slot.presentation(context); + } catch (error) { + console.error( + `[plugin:${slot.pluginId}] experimental_sidebarSectionAction "${slot.id}" presentation failed: ${describeError(error)}`, + ); + return null; + } + if (presentation === null) return null; + if ( + typeof presentation !== "object" || + typeof presentation.title !== "string" || + presentation.title.trim().length === 0 || + typeof presentation.icon !== "string" || + !isIconName(presentation.icon) || + (presentation.pressed !== undefined && + typeof presentation.pressed !== "boolean") || + (presentation.disabled !== undefined && + typeof presentation.disabled !== "boolean") + ) { + console.error( + `[plugin:${slot.pluginId}] experimental_sidebarSectionAction "${slot.id}" returned an invalid presentation`, + ); + return null; + } + return { + key: `${slot.pluginId}:${slot.id}`, + icon: presentation.icon, + presentation: { ...presentation, title: presentation.title.trim() }, + context, + slot, + }; +} + +export function usePluginSidebarSectionActions( + context: PluginSidebarSectionActionContext, + enabled = true, +): PluginSidebarSectionActionResolution { + const { sidebarSectionActions } = usePluginSlots(); + const [lastActivatedKey, setLastActivatedKey] = useState(null); + const actions = useMemo(() => { + if (!enabled) return []; + return sidebarSectionActions.flatMap((slot) => { + const resolved = resolvePresentation(slot, context); + return resolved ? [resolved] : []; + }); + }, [context, enabled, sidebarSectionActions]); + const pressedActions = actions.filter( + (action) => action.presentation.pressed === true, + ); + const inlineAction = + pressedActions.find((action) => action.key === lastActivatedKey) ?? + pressedActions[0] ?? + actions.find((action) => action.slot.placement === "inline-preferred") ?? + null; + const overflowActions = actions.filter((action) => action !== inlineAction); + const run = useCallback((action: ResolvedPluginSidebarSectionAction) => { + setLastActivatedKey(action.key); + try { + void Promise.resolve(action.slot.run(action.context)).catch((error) => { + console.error( + `[plugin:${action.slot.pluginId}] experimental_sidebarSectionAction "${action.slot.id}" failed: ${describeError(error)}`, + ); + }); + } catch (error) { + console.error( + `[plugin:${action.slot.pluginId}] experimental_sidebarSectionAction "${action.slot.id}" failed: ${describeError(error)}`, + ); + } + }, []); + return { + hasPressed: pressedActions.length > 0, + hasPressedOverflow: overflowActions.some( + (action) => action.presentation.pressed === true, + ), + inlineAction, + overflowActions, + run, + }; +} + +export function PluginSidebarSectionInlineAction({ + action, + onRun, +}: { + action: ResolvedPluginSidebarSectionAction; + onRun: (action: ResolvedPluginSidebarSectionAction) => void; +}) { + const label = activationLabel(action); + return ( + + + + + {label} + + ); +} + +function PluginSidebarSectionOverflowItem({ + action, + onRun, +}: { + action: ResolvedPluginSidebarSectionAction; + onRun: (action: ResolvedPluginSidebarSectionAction) => void; +}) { + const modifierHeldRef = useRef(false); + return ( + { + modifierHeldRef.current = event.metaKey || event.ctrlKey; + }} + onPointerDown={(event) => { + modifierHeldRef.current = event.metaKey || event.ctrlKey; + }} + onSelect={() => { + const canActivate = + !requiresPrimaryModifier(action) || modifierHeldRef.current; + modifierHeldRef.current = false; + if (canActivate) onRun(action); + }} + > + + ); +} + +export function PluginSidebarSectionOverflowItems({ + actions, + onRun, +}: { + actions: readonly ResolvedPluginSidebarSectionAction[]; + onRun: (action: ResolvedPluginSidebarSectionAction) => void; +}) { + return actions.map((action) => ( + + )); +} diff --git a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx index 09e37a21c0..e6a90b793c 100644 --- a/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.interactions.test.tsx @@ -1,10 +1,12 @@ // @vitest-environment jsdom import { + act, cleanup, fireEvent, render, screen, + within, waitFor, } from "@testing-library/react"; import type { ThreadListEntry } from "@bb/domain"; @@ -20,6 +22,11 @@ import { type ProjectThreadListState, } from "./ProjectRow"; import { buildSidebarEntitySectionId } from "@bb/client-core"; +import { + resetPluginSlotStoreForTest, + setPluginSlotRegistrations, +} from "@/lib/plugin-slots"; +import { sidebarFullscreenSectionIdAtom } from "./sidebarCollapsedAtoms"; const mockUpdateEnvironment = vi.hoisted(() => ({ mutate: vi.fn(), @@ -173,10 +180,223 @@ function expectCollapsedActivityAtSidebarEdge(label: string) { describe("ProjectRow interactions", () => { afterEach(() => { cleanup(); + resetPluginSlotStoreForTest(); mockDraftThreadIds.current = new Set(); vi.clearAllMocks(); }); + it("lets a plugin fullscreen one section with a pressed inline action", () => { + setPluginSlotRegistrations("thread-organizer", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + sidebarSectionActions: [ + { + id: "fullscreen", + placement: "inline-preferred", + experimental_requiresPrimaryModifier: true, + presentation: ({ section, sidebar }) => { + const pressed = + sidebar.experimental_fullscreenSectionId === section.id; + return { + title: pressed ? "Exit Full Screen" : "Full Screen Section", + icon: pressed ? "Minimize2" : "Maximize2", + pressed, + }; + }, + run: ({ section, sidebar }) => { + sidebar.experimental_setFullscreenSection( + sidebar.experimental_fullscreenSectionId === section.id + ? null + : section.id, + ); + }, + }, + ], + fileOpeners: [], + messageDirectives: [], + }); + const store = createStore(); + store.set(sidebarFullscreenSectionIdAtom, null); + const queryClient = new QueryClient(); + const sections = [ + { id: "sec_planning", name: "Planning", experimental_icon: "ListTodo" }, + { id: "sec_building", name: "Building", experimental_icon: "ToolCase" }, + ]; + + render( + + + + + 0} + sections={sections} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={sections.map((section) => + buildSidebarEntitySectionId("section", section.id), + )} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + /> + + + + , + ); + + const planning = document.querySelector( + '[data-sidebar-section-id="sec_planning"]', + )!; + const enter = within(planning).getByRole("button", { + name: "Full Screen Section (Command/Ctrl-click)", + }); + fireEvent.click(enter); + expect( + document.querySelector('[data-sidebar-section-id="sec_building"]'), + ).not.toBeNull(); + + fireEvent.click(enter, { metaKey: true }); + + expect( + document.querySelector('[data-sidebar-section-id="sec_building"]'), + ).toBeNull(); + const exit = screen.getByRole("button", { + name: "Exit Full Screen (Command/Ctrl-click)", + }); + expect(exit.getAttribute("aria-pressed")).toBe("true"); + expect(exit.querySelector('[data-icon="Minimize2"]')).not.toBeNull(); + + fireEvent.click(exit); + expect( + document.querySelector('[data-sidebar-section-id="sec_building"]'), + ).toBeNull(); + fireEvent.keyDown(exit, { ctrlKey: true, key: "Enter" }); + expect( + document.querySelector('[data-sidebar-section-id="sec_building"]'), + ).not.toBeNull(); + }); + + it("exits fullscreen if the plugin action that owns it disappears", async () => { + setPluginSlotRegistrations("thread-organizer", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + sidebarSectionActions: [ + { + id: "fullscreen", + placement: "inline-preferred", + presentation: ({ section, sidebar }) => ({ + title: + sidebar.experimental_fullscreenSectionId === section.id + ? "Exit Full Screen" + : "Full Screen Section", + icon: + sidebar.experimental_fullscreenSectionId === section.id + ? "Minimize2" + : "Maximize2", + pressed: sidebar.experimental_fullscreenSectionId === section.id, + }), + run: ({ section, sidebar }) => { + sidebar.experimental_setFullscreenSection(section.id); + }, + }, + ], + fileOpeners: [], + messageDirectives: [], + }); + const store = createStore(); + store.set(sidebarFullscreenSectionIdAtom, null); + const sections = [ + { id: "sec_planning", name: "Planning", experimental_icon: "ListTodo" }, + { id: "sec_building", name: "Building", experimental_icon: "ToolCase" }, + ]; + + render( + + + + + 0} + sections={sections} + collapsedThreadIds={new Set()} + collapsedEnvironmentIds={new Set()} + onToggleThreadCollapsed={vi.fn()} + onToggleEnvironmentCollapsed={vi.fn()} + topLevelSectionOrder={sections.map((section) => + buildSidebarEntitySectionId("section", section.id), + )} + onTopLevelSectionOrderChange={vi.fn()} + pinnedReorderPending={false} + pinnedThreads={[]} + onReorderPinnedThread={vi.fn()} + /> + + + + , + ); + + const planning = document.querySelector( + '[data-sidebar-section-id="sec_planning"]', + )!; + fireEvent.click( + within(planning).getByRole("button", { name: "Full Screen Section" }), + ); + expect(store.get(sidebarFullscreenSectionIdAtom)).toBe("sec_planning"); + + act(() => { + setPluginSlotRegistrations("thread-organizer", { + homepageSections: [], + settingsSections: [], + navPanels: [], + threadPanelActions: [], + sidebarFooterActions: [], + sidebarSectionActions: [ + { + id: "unrelated", + placement: "menu", + presentation: () => ({ title: "Other action", icon: "Settings" }), + run: () => {}, + }, + ], + fileOpeners: [], + messageDirectives: [], + }); + }); + + await waitFor(() => { + expect(store.get(sidebarFullscreenSectionIdAtom)).toBeNull(); + }); + expect( + document.querySelector('[data-sidebar-section-id="sec_building"]'), + ).not.toBeNull(); + }); + it("places the project disclosure after its label and keeps root threads flush", () => { const result = renderProjectRow(vi.fn(), { status: "ready", diff --git a/apps/app/src/components/sidebar/ProjectRow.tsx b/apps/app/src/components/sidebar/ProjectRow.tsx index c59648f214..a44905190a 100644 --- a/apps/app/src/components/sidebar/ProjectRow.tsx +++ b/apps/app/src/components/sidebar/ProjectRow.tsx @@ -1,13 +1,14 @@ import { memo, useCallback, + useEffect, useMemo, useState, type CSSProperties, type MouseEventHandler, type ReactNode, } from "react"; -import { useAtomValue, useSetAtom } from "jotai"; +import { useAtom, useAtomValue, useSetAtom } from "jotai"; import { DndContext, DragOverlay, useDroppable } from "@dnd-kit/core"; import { SortableContext, @@ -32,6 +33,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { EmptyState } from "@bb/shared-ui/empty-state"; @@ -102,6 +104,7 @@ import { SidebarSectionRow } from "./SidebarSectionRow"; import { TopLevelSidebarSection } from "./TopLevelSidebarSection"; import { sidebarCollapsedThreadSectionsAtom, + sidebarFullscreenSectionIdAtom, type CollapsibleSidebarSectionId, type SidebarSectionId, } from "./sidebarCollapsedAtoms"; @@ -135,6 +138,13 @@ import { type BuiltInSidebarSectionOptionsById, } from "./BuiltInSidebarSection"; import { SectionThreadDndProvider } from "./SectionThreadDndContext"; +import { useIsCompactViewport } from "@bb/shared-ui/hooks/use-compact-viewport"; +import { + PluginSidebarSectionInlineAction, + PluginSidebarSectionOverflowItems, + usePluginSidebarSectionActions, +} from "./PluginSidebarSectionActions"; +import { usePluginSlots } from "@/lib/plugin-slots"; // Pin the project row plus this many parent levels (parent threads, // worktree group headers); rows deeper than the cap render non-sticky so a deep @@ -205,6 +215,7 @@ interface SectionThreadTreeProps { renderTopLevelSectionHeaderActions?: ( section: SidebarSectionDefinition, ) => TopLevelSectionHeaderActions; + visibleSectionCount?: number; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; } @@ -304,6 +315,7 @@ interface ThreadTreeItemRowProps { onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; + visibleSectionCount?: number; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -326,6 +338,7 @@ interface SectionTreeItemRowProps { onRenameSection?: (section: SidebarSectionDefinition) => void; onRemoveSection?: (section: SidebarSectionDefinition) => void; renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; + visibleSectionCount?: number; onToggleThreadCollapsed: (threadId: string) => void; onToggleEnvironmentCollapsed: (environmentId: string) => void; consumeClickSuppression?: ConsumeDragClickSuppression; @@ -1188,6 +1201,7 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onRenameSection, onRemoveSection, renderTopLevelSectionHeaderActions, + visibleSectionCount, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, consumeClickSuppression, @@ -1211,6 +1225,7 @@ const ThreadTreeItemRow = memo(function ThreadTreeItemRow({ onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} renderTopLevelSectionHeaderActions={renderTopLevelSectionHeaderActions} + visibleSectionCount={visibleSectionCount} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} consumeClickSuppression={consumeClickSuppression} @@ -1332,6 +1347,7 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ onRenameSection, onRemoveSection, renderTopLevelSectionHeaderActions, + visibleSectionCount = 0, onToggleThreadCollapsed, onToggleEnvironmentCollapsed, consumeClickSuppression, @@ -1342,10 +1358,16 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ sortableStyle, }: SectionTreeItemRowProps) { const [isTopLevelActionsOpen, setIsTopLevelActionsOpen] = useState(false); + const isCompactViewport = useIsCompactViewport(); + const [fullscreenSectionId, setFullscreenSectionId] = useAtom( + sidebarFullscreenSectionIdAtom, + ); const collapsedSections = useAtomValue(sidebarCollapsedThreadSectionsAtom); const setCollapsedSections = useSetAtom(sidebarCollapsedThreadSectionsAtom); const sectionKey = section.key; - const isCollapsed = collapsedSections.includes(sectionKey); + const isTopLevelSection = variant === "section" && depthOffset === 0; + const isFullscreen = isTopLevelSection && fullscreenSectionId === section.id; + const isCollapsed = collapsedSections.includes(sectionKey) && !isFullscreen; const handleToggleCollapsed = useCallback(() => { setCollapsedSections((current) => current.includes(sectionKey) @@ -1371,6 +1393,49 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ () => getProjectThreadItemDescendants(section.items), [section.items], ); + const pluginActionContext = useMemo( + () => ({ + section: { + id: section.id, + name: section.name, + experimental_icon: section.experimental_icon ?? null, + depth: headerDepth, + threadCount: sectionThreads.length, + }, + sidebar: { + experimental_fullscreenSectionId: fullscreenSectionId, + experimental_visibleSectionCount: visibleSectionCount, + experimental_setFullscreenSection: (sectionId: string | null) => { + setFullscreenSectionId(sectionId); + }, + }, + isCompactViewport, + }), + [ + fullscreenSectionId, + headerDepth, + isCompactViewport, + section.experimental_icon, + section.id, + section.name, + sectionThreads.length, + setFullscreenSectionId, + visibleSectionCount, + ], + ); + const pluginSectionActions = usePluginSidebarSectionActions( + pluginActionContext, + isTopLevelSection, + ); + useEffect(() => { + if (isFullscreen && !pluginSectionActions.hasPressed) { + setFullscreenSectionId(null); + } + }, [ + isFullscreen, + pluginSectionActions.hasPressed, + setFullscreenSectionId, + ]); const { itemKeys, estimateRows, getNavigationEntries, alwaysMountedKeys } = useWindowedThreadItems({ items: section.items, @@ -1414,6 +1479,7 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ onCreateThreadInSection={onCreateThreadInSection} onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} + visibleSectionCount={visibleSectionCount} onToggleThreadCollapsed={onToggleThreadCollapsed} onToggleEnvironmentCollapsed={onToggleEnvironmentCollapsed} sectionDnd={sectionDnd} @@ -1437,19 +1503,32 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ ) : null; - if (variant === "section" && depthOffset === 0) { + if (isTopLevelSection) { const externalHeaderActions = renderTopLevelSectionHeaderActions?.(section); - const hasMenuActions = Boolean(onRenameSection || onRemoveSection); + const hasMenuActions = Boolean( + onRenameSection || + onRemoveSection || + pluginSectionActions.overflowActions.length > 0, + ); const hasTopLevelActions = Boolean( externalHeaderActions?.actions || + pluginSectionActions.inlineAction || hasMenuActions || onCreateThreadInSection, ); const topLevelActionsOpen = - isTopLevelActionsOpen || externalHeaderActions?.actionsOpen === true; + isTopLevelActionsOpen || + externalHeaderActions?.actionsOpen === true || + pluginSectionActions.hasPressed; const topLevelActionControls = ( <> {externalHeaderActions?.actions} + {pluginSectionActions.inlineAction ? ( + + ) : null} {hasMenuActions ? ( @@ -1457,7 +1536,11 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ type="button" variant="ghost" size="icon" - aria-label={`${section.name} section actions`} + aria-label={`${section.name} section actions${ + pluginSectionActions.hasPressedOverflow + ? ", active plugin action" + : "" + }`} className={cn( "rounded-md p-0 text-subtle-foreground hover:bg-transparent hover:text-foreground", SIDEBAR_MORE_ACTION_TRIGGER_CLASS, @@ -1465,7 +1548,11 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ > @@ -1485,6 +1572,14 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ Remove ) : null} + {(onRenameSection || onRemoveSection) && + pluginSectionActions.overflowActions.length > 0 ? ( + + ) : null} + ) : null} @@ -1529,6 +1624,7 @@ const SectionTreeItemRow = memo(function SectionTreeItemRow({ return ( void; onRemoveSection?: (section: SidebarSectionDefinition) => void; renderTopLevelSectionHeaderActions?: SectionThreadTreeProps["renderTopLevelSectionHeaderActions"]; + visibleSectionCount?: number; } // Windowing inputs shared by every list of ProjectThreadItems: stable keys, @@ -1826,6 +1924,7 @@ function SectionThreadTreeItems({ onRenameSection, onRemoveSection, renderTopLevelSectionHeaderActions, + visibleSectionCount, }: SectionThreadTreeItemsProps) { const { itemKeys, estimateRows, getNavigationEntries, alwaysMountedKeys } = useWindowedThreadItems({ @@ -1864,6 +1963,7 @@ function SectionThreadTreeItems({ renderTopLevelSectionHeaderActions={ renderTopLevelSectionHeaderActions } + visibleSectionCount={visibleSectionCount} sectionDnd={sectionDnd ?? undefined} /> ); @@ -1986,6 +2086,10 @@ export const ChronologicalSectionThreadSections = memo( renderPinnedSection, renderThreadsSection, }: ChronologicalSectionThreadSectionsProps) { + const [fullscreenSectionId, setFullscreenSectionId] = useAtom( + sidebarFullscreenSectionIdAtom, + ); + const { sidebarSectionActions } = usePluginSlots(); const threads = threadListState.status === "ready" ? threadListState.threads @@ -2087,6 +2191,24 @@ export const ChronologicalSectionThreadSections = memo( const sectionItems = renderedRootItems.filter( (item) => item.kind === "section", ); + const availableSectionIds = useMemo( + () => new Set(sectionItems.map((item) => item.group.id)), + [sectionItems], + ); + useEffect(() => { + if ( + fullscreenSectionId !== null && + (sidebarSectionActions.length === 0 || + !availableSectionIds.has(fullscreenSectionId)) + ) { + setFullscreenSectionId(null); + } + }, [ + availableSectionIds, + fullscreenSectionId, + setFullscreenSectionId, + sidebarSectionActions.length, + ]); const looseItems = renderedRootItems.filter( (item) => item.kind !== "section", ); @@ -2109,6 +2231,7 @@ export const ChronologicalSectionThreadSections = memo( onRenameSection={onRenameSection} onRemoveSection={onRemoveSection} renderTopLevelSectionHeaderActions={renderTopLevelSectionHeaderActions} + visibleSectionCount={topLevelSectionOrder.length} /> ); @@ -2207,8 +2330,11 @@ export const ChronologicalSectionThreadSections = memo( pinned: renderPinnedSection?.(consumeClickSuppression), threads: renderThreadsSection?.(threadsContent, consumeClickSuppression), }; + const visibleTopLevelSectionOrder = fullscreenSectionId + ? [buildSidebarEntitySectionId("section", fullscreenSectionId)] + : topLevelSectionOrder; const orderedSections = ( - + {(sectionId) => { const builtInSection = builtInSections && configuredBuiltInSections diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx index 1619ee02df..8caa6e8d31 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.test.tsx @@ -47,6 +47,32 @@ describe("SidebarSectionRow", () => { expect(row?.style.paddingLeft).toBe("32px"); }); + it("renders an explicitly configured icon before the section name", () => { + const result = render( + , + ); + + const icon = result.container.querySelector('[data-icon="ToolCase"]'); + const label = screen.getByText("Building"); + + expect(icon).not.toBeNull(); + expect(icon?.compareDocumentPosition(label) ?? 0).toEqual( + expect.any(Number), + ); + expect( + (icon?.compareDocumentPosition(label) ?? 0) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).not.toBe(0); + }); + it("rolls hidden split threads up to the collapsed section row", () => { const store = createStore(); store.set(splitLayoutAtom, { diff --git a/apps/app/src/components/sidebar/SidebarSectionRow.tsx b/apps/app/src/components/sidebar/SidebarSectionRow.tsx index 5e70ab20ac..505354b381 100644 --- a/apps/app/src/components/sidebar/SidebarSectionRow.tsx +++ b/apps/app/src/components/sidebar/SidebarSectionRow.tsx @@ -13,7 +13,7 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; -import { Icon } from "@bb/shared-ui/icon"; +import { Icon, isIconName } from "@bb/shared-ui/icon"; import { SidebarStickyTier } from "@/components/ui/sidebar.js"; import { Tooltip, TooltipContent, TooltipTrigger } from "@bb/shared-ui/tooltip"; import { @@ -57,6 +57,7 @@ function stopActionsClick(event: MouseEvent) { interface SidebarSectionRowProps { // Leaf segment shown on the header ("Q3"). name: string; + experimental_icon?: string | null; label: string; // Render depth (section nesting + section offset); drives indentation. depth: number; @@ -79,6 +80,7 @@ interface SidebarSectionRowProps { // project row while still mirroring parent-thread disclosure behavior. function SidebarSectionRowComponent({ name, + experimental_icon, label, depth, activity, @@ -93,6 +95,10 @@ function SidebarSectionRowComponent({ onRemove, stickyLevel, }: SidebarSectionRowProps) { + const sectionIcon = + experimental_icon && isIconName(experimental_icon) + ? experimental_icon + : null; const [isActionsOpen, setIsActionsOpen] = useState(false); const collapsedSplitIndicator = useThreadGroupSplitIndicator( collapsedThreads, @@ -162,7 +168,14 @@ function SidebarSectionRowComponent({ onClick={onToggleCollapsed} className="absolute inset-0 rounded-md outline-none ring-sidebar-ring focus-visible:ring-2" /> - + + {sectionIcon ? ( +