From b645d981dfe6defe6c3ccebf074e389773cfe222 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 11 Sep 2026 21:40:21 +0000 Subject: [PATCH 1/6] feat(sidebar): fold the project scope into the search row The sidebar header spent two rows on search, new thread, project scope and new project. Search now owns the single row and the project scope collapses to an icon that sits with new project and new thread; the same searchable project combobox opens under the search field and lists All projects as its default row. --- apps/web/src/components/Sidebar.logic.test.ts | 20 +- apps/web/src/components/Sidebar.logic.ts | 8 +- apps/web/src/components/Sidebar.tsx | 182 ++++----------- .../sidebar/SidebarThreadHeader.tsx | 209 ++++++++++++++++++ 4 files changed, 266 insertions(+), 153 deletions(-) create mode 100644 apps/web/src/components/sidebar/SidebarThreadHeader.tsx 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 c269b73ee761..812664ae876f 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -45,7 +45,6 @@ import { CircleDashedIcon, ClockIcon, FolderIcon, - FolderPlusIcon, GitBranchIcon, PinIcon, PinOffIcon, @@ -228,6 +227,7 @@ import { } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, 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 { @@ -2358,21 +2358,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( () => @@ -2447,6 +2445,8 @@ export default function Sidebar() { ); // Safari can send a click after Ctrl+click opens settings. Ignore that one // selection, then clear the guard when the picker opens again. + // Anchor for the scope popup: the header search field, not its icon trigger. + const headerSearchRef = useRef(null); const suppressNextScopeChangeRef = useRef(false); const highlightedProjectScopeKeyRef = useRef(null); const handleProjectSettings = useCallback( @@ -4314,99 +4314,10 @@ export default function Sidebar() { // 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 ? ( - - - + ) : ( - + )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - - - - } - > - - - 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..2492bfbafb59 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -0,0 +1,209 @@ +/** + * 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 takes a pressed look while a project is selected, so + * narrowing to a project is never invisible state. + * + * 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 Ref, + 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?: Ref; + /** 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-sidebar-muted-foreground" + /> + {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, + isActive, + children, + ...rest +}: { + /** Accessible name; also the tooltip unless `tooltip` says more. */ + label: string; + tooltip?: ReactNode; + className?: string | undefined; + isActive?: boolean | undefined; + children?: ReactNode; +} & Omit, "children" | "className">) { + return ( + + + } + > + {children} + {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */} + + + {tooltip} + + ); +} From b795f27227c5e9db0c40986c268366c93dea325d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 11 Sep 2026 21:50:34 +0000 Subject: [PATCH 2/6] feat(sidebar): drop the icon group border and pressed scope state The header icon group loses its ring, the scope icon no longer takes a selected background while a project is scoped (the favicon already says so), and the search icon and placeholder use the same muted icon color as new thread. --- apps/web/src/components/Sidebar.tsx | 3 --- .../src/components/sidebar/SidebarThreadHeader.tsx | 13 +++++-------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 812664ae876f..1f47ee4ea923 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -4350,9 +4350,6 @@ export default function Sidebar() { ? `Filter threads by project: ${scopedProjectGroup.displayName}` : "Filter threads by project" } - // A pressed look while scoped: the icon is the only - // chrome that says the list is narrowed to one project. - isActive={scopedProjectGroup !== null} /> } > diff --git a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx index 2492bfbafb59..1e34379508d8 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHeader.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHeader.tsx @@ -3,8 +3,8 @@ * * 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 takes a pressed look while a project is selected, so - * narrowing to a project is never invisible state. + * The scope icon swaps to the project favicon while a project is selected, + * so narrowing to a project is never invisible state. * * 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 @@ -85,7 +85,7 @@ export function SidebarThreadHeader({ ref={searchFieldRef} className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground" > - + {isSearching ? (