diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 33157e7b4b4b..f9a62c40a511 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => { { value: "alpha", label: "Alpha workspace" }, { value: "beta", label: "Beta tools" }, ] as const; - const filter = (activeScopeKey: string | null, query: string) => + const filter = (query: string) => filterSidebarProjectScopeItems({ items, - activeScopeKey, query, matches: (item, candidate) => item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), }); - it("omits the reset row when the sidebar is already unscoped", () => { - expect(filter(null, "")).toEqual(items.slice(1)); + it("shows the default row first while the query is empty", () => { + expect(filter("")).toEqual(items); + expect(filter(" ")).toEqual(items); }); - it("shows the reset row first while a project scope is active", () => { - expect(filter("alpha", "")).toEqual(items); - }); - - it("hides the reset row while filtering an active scope", () => { - expect(filter("alpha", "all")).toEqual([]); + it("hides the default row while filtering", () => { + expect(filter("all")).toEqual([]); }); it("returns matching projects in source order and supports no-match results", () => { - expect(filter(null, "WORK")).toEqual([items[1]]); - expect(filter(null, "missing")).toEqual([]); + expect(filter("WORK")).toEqual([items[1]]); + expect(filter("missing")).toEqual([]); }); }); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 0150eb473d51..50650ed389dc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -894,16 +894,12 @@ export function searchSidebarThreads< export function filterSidebarProjectScopeItems(input: { items: readonly TItem[]; - activeScopeKey: string | null; query: string; matches: (item: TItem, query: string) => boolean; }): readonly TItem[] { - const projectItems = input.items.filter((item) => item.value !== "all"); const query = input.query.trim(); - if (query.length > 0) { - return projectItems.filter((item) => input.matches(item, query)); - } - return input.activeScopeKey === null ? projectItems : input.items; + if (query.length === 0) return input.items; + return input.items.filter((item) => item.value !== "all" && input.matches(item, query)); } export interface SidebarProjectScopeMenuState { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2bc0c6bab019..7aa49d2bf68f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,12 +45,10 @@ import { CircleDashedIcon, ClockIcon, FolderIcon, - FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, PlusIcon, - SearchIcon, SettingsIcon, SquarePenIcon, TerminalIcon, @@ -217,7 +215,6 @@ import { import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; -import { Input } from "./ui/input"; import { Combobox, ComboboxEmpty, @@ -228,8 +225,9 @@ import { ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; -import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; +import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; +import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; import { @@ -2367,21 +2365,19 @@ export default function Sidebar() { const projectScopeFilter = useComboboxFilter(); // Filtering derives from the same React state that controls the input, so // the visible query and the visible list can never desync — the peer wiring - // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope - // reset, not a searchable entry: it only shows while a project scope is - // active (there is something to reset) and the query is empty, so it can't - // outrank a project match under autoHighlight and no-hit queries reach the - // empty state. + // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default + // row, not a searchable entry: it heads the list while the query is empty and + // drops out while filtering, so it can't outrank a project match under + // autoHighlight and no-hit queries reach the empty state. const filteredProjectScopeItems = useMemo( () => filterSidebarProjectScopeItems({ items: projectScopeItems, - activeScopeKey: projectScopeKey, query: projectScopeMenuState.query, matches: (item, query) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), }), - [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], + [projectScopeFilter, projectScopeItems, projectScopeMenuState.query], ); const scopedProjectGroup = useMemo( () => @@ -2454,6 +2450,8 @@ export default function Sidebar() { }, [isMobile, router, setOpenMobile], ); + // Anchor for the scope popup: the header search field, not its icon trigger. + const headerSearchRef = useRef(null); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. const suppressNextScopeChangeRef = useRef(false); @@ -4322,100 +4320,11 @@ export default function Sidebar() { fixedHeader={ // Lifted above the stage backdrop, whose fade bleeds below the // header and would otherwise paint across the search row's outline. - -
-
- - { - setThreadSearchQuery(event.currentTarget.value); - setActiveSearchResultIndex(0); - }} - onKeyDown={handleThreadSearchKeyDown} - placeholder="Search threads or PRs" - aria-label="Search threads" - role="combobox" - aria-autocomplete="list" - aria-expanded={isSearchingThreads && threadSearchResults.length > 0} - aria-controls={ - isSearchingThreads && threadSearchResults.length > 0 - ? "sidebar-thread-search-results" - : undefined - } - aria-activedescendant={ - isSearchingThreads && threadSearchResults[activeSearchResultIndex] - ? `sidebar-thread-search-result-${activeSearchResultIndex}` - : undefined - } - className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" - /> - {isSearchingThreads ? ( - - ) : null} -
-
- - - } - > - - - - {projectGroups.length > 1 ? ( - - - {newThreadShortcutLabel - ? `New thread (${newThreadShortcutLabel})` - : "New thread"} - - - New thread in current project: Shift+click - {newThreadInProjectShortcutLabel - ? ` (${newThreadInProjectShortcutLabel})` - : ""} - - - ) : newThreadShortcutLabel ? ( - `New thread (${newThreadShortcutLabel})` - ) : ( - "New thread" - )} - - -
-
- {projectGroups.length > 0 ? ( -
+ + 0} + projectScope={ } > {scopedProjectGroup ? ( + // Wrapped so the button's direct-child svg color rule cannot override + // a project's own icon color. ) : ( - + )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - {scopedProjectGroup && showProjectEnvironments ? ( - - ) : null} - - - - } - > - - - New project - -
- ) : null} + } + onNewProject={openAddProjectCommandPalette} + onNewThread={handleNewThreadClick} + newThreadDisabled={projects.length === 0} + newThreadShortcutLabel={newThreadShortcutLabel} + newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} + showNewThreadInProjectHint={projectGroups.length > 1} + searchInputRef={threadSearchInputRef} + searchQuery={threadSearchQuery} + onSearchQueryChange={(value) => { + setThreadSearchQuery(value); + setActiveSearchResultIndex(0); + }} + onSearchKeyDown={handleThreadSearchKeyDown} + isSearching={isSearchingThreads} + searchResultCount={threadSearchResults.length} + activeSearchResultIndex={activeSearchResultIndex} + onClearSearch={clearThreadSearch} + />
} > diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx new file mode 100644 index 000000000000..878235615b39 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -0,0 +1,208 @@ +/** + * The sidebar header: one row holding search, project scope and new thread. + * + * Search owns the row's text and spans it. Project scope collapses to an icon + * that sits with new-project and new-thread as a segmented group at the end. + * The scope icon swaps to the project favicon while a project is selected, + * so the header still names the scope after the row that showed it is gone. + * + * The scope picker itself is passed in: its combobox state lives with the rest + * of the sidebar's scope logic. `searchFieldRef` lands on the search field so + * the picker's popup can anchor to that width rather than to its 28px trigger. + */ +import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; +import { + type ComponentProps, + type KeyboardEvent as ReactKeyboardEvent, + type MouseEvent as ReactMouseEvent, + type ReactNode, + type RefObject, +} from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { SidebarMenuButton } from "../ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +export interface SidebarThreadHeaderProps { + /** Lands on the search field so a popup can anchor to its width. */ + searchFieldRef?: RefObject; + /** Without projects there is nothing to scope, so those controls stay out. */ + hasProjects: boolean; + /** The project scope combobox, rendered as the first icon of the group. */ + projectScope: ReactNode; + onNewProject: () => void; + /** Receives the click so Shift+click can skip the project picker. */ + onNewThread: (event: ReactMouseEvent) => void; + newThreadDisabled: boolean; + newThreadShortcutLabel: string | null | undefined; + newThreadInProjectShortcutLabel: string | null | undefined; + /** Shift+click only matters once there is more than one project to pick. */ + showNewThreadInProjectHint: boolean; + searchInputRef: RefObject; + searchQuery: string; + onSearchQueryChange: (value: string) => void; + onSearchKeyDown: (event: ReactKeyboardEvent) => void; + isSearching: boolean; + searchResultCount: number; + activeSearchResultIndex: number; + onClearSearch: () => void; +} + +export function SidebarThreadHeader({ + searchFieldRef, + hasProjects, + projectScope, + onNewProject, + onNewThread, + newThreadDisabled, + newThreadShortcutLabel, + newThreadInProjectShortcutLabel, + showNewThreadInProjectHint, + searchInputRef, + searchQuery, + onSearchQueryChange, + onSearchKeyDown, + isSearching, + searchResultCount, + activeSearchResultIndex, + onClearSearch, +}: SidebarThreadHeaderProps) { + const resultsVisible = isSearching && searchResultCount > 0; + // Results shrink as the query narrows, so the active index can outrun the + // list; pointing aria-activedescendant at a removed option strands the + // screen reader on nothing. + const activeResultExists = resultsVisible && activeSearchResultIndex < searchResultCount; + const newThreadLabel = newThreadShortcutLabel + ? `New thread (${newThreadShortcutLabel})` + : "New thread"; + + return ( +
+
+ + onSearchQueryChange(event.currentTarget.value)} + onKeyDown={onSearchKeyDown} + placeholder="Search" + aria-label="Search threads" + role="combobox" + aria-autocomplete="list" + aria-expanded={resultsVisible} + aria-controls={resultsVisible ? "sidebar-thread-search-results" : undefined} + aria-activedescendant={ + activeResultExists + ? `sidebar-thread-search-result-${activeSearchResultIndex}` + : undefined + } + className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-[var(--sidebar-icon-color)]" + /> + {isSearching ? ( + + ) : null} +
+ {/* Segmented well: the icons read as one control instead of three loose + buttons competing with the search field beside them. */} +
+ {hasProjects ? ( + <> + {projectScope} + + + + + ) : null} + + {newThreadLabel} + + New thread in current project: Shift+click + {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""} + + + ) : ( + newThreadLabel + ) + } + disabled={newThreadDisabled} + onClick={onNewThread} + > + + +
+
+ ); +} + +/** + * Icon button with a tooltip, sized for the header's segmented pair. Spreads + * unknown props through so it can serve as a popup trigger's render target, + * which injects its own handlers, ref and aria state. + */ +export function SidebarHeaderIconButton({ + label, + tooltip = label, + className, + children, + ...rest +}: { + /** Accessible name; also the tooltip unless `tooltip` says more. */ + label: string; + tooltip?: ReactNode; + className?: string | undefined; + children?: ReactNode; +} & Omit< + ComponentProps, + "children" | "className" | "tooltip" | "isActive" | "aria-label" +>) { + return ( + + + } + > + {children} + {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */} + + + {tooltip} + + ); +}