authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 12:50:34-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 12:50:34-03:00
logd1d15c67f4a5fb82fd8d5e01e5e3b288296789c3
tree6e05b852b4f72385eea97d165b87a159d7dad470
parentb1e223e2b0d87124883b1410ab52dd6a1338e40d
signature Signed by PGP key B5690EEEBB952194

feat(sidebar): fold the project scope into the search row (#11315)


4 files changed, 266 insertions(+), 161 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+8-12
......@@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => {
828828 { value: "alpha", label: "Alpha workspace" },
829829 { value: "beta", label: "Beta tools" },
830830 ] as const;
831 const filter = (activeScopeKey: string | null, query: string) =>
831 const filter = (query: string) =>
832832 filterSidebarProjectScopeItems({
833833 items,
834 activeScopeKey,
835834 query,
836835 matches: (item, candidate) =>
837836 item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),
838837 });
839838
840 it("omits the reset row when the sidebar is already unscoped", () => {
841 expect(filter(null, "")).toEqual(items.slice(1));
839 it("shows the default row first while the query is empty", () => {
840 expect(filter("")).toEqual(items);
841 expect(filter(" ")).toEqual(items);
842842 });
843843
844 it("shows the reset row first while a project scope is active", () => {
845 expect(filter("alpha", "")).toEqual(items);
846 });
847
848 it("hides the reset row while filtering an active scope", () => {
849 expect(filter("alpha", "all")).toEqual([]);
844 it("hides the default row while filtering", () => {
845 expect(filter("all")).toEqual([]);
850846 });
851847
852848 it("returns matching projects in source order and supports no-match results", () => {
853 expect(filter(null, "WORK")).toEqual([items[1]]);
854 expect(filter(null, "missing")).toEqual([]);
849 expect(filter("WORK")).toEqual([items[1]]);
850 expect(filter("missing")).toEqual([]);
855851 });
856852});
857853
apps/web/src/components/Sidebar.logic.ts+2-6
......@@ -894,16 +894,12 @@ export function searchSidebarThreads<
894894
895895export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
896896 items: readonly TItem[];
897 activeScopeKey: string | null;
898897 query: string;
899898 matches: (item: TItem, query: string) => boolean;
900899}): readonly TItem[] {
901 const projectItems = input.items.filter((item) => item.value !== "all");
902900 const query = input.query.trim();
903 if (query.length > 0) {
904 return projectItems.filter((item) => input.matches(item, query));
905 }
906 return input.activeScopeKey === null ? projectItems : input.items;
901 if (query.length === 0) return input.items;
902 return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
907903}
908904
909905export interface SidebarProjectScopeMenuState {
apps/web/src/components/Sidebar.tsx+48-143
......@@ -46,12 +46,10 @@ import {
4646 CircleDashedIcon,
4747 ClockIcon,
4848 FolderIcon,
49 FolderPlusIcon,
5049 GitBranchIcon,
5150 PinIcon,
5251 PinOffIcon,
5352 PlusIcon,
54 SearchIcon,
5553 SettingsIcon,
5654 SquarePenIcon,
5755 TerminalIcon,
......@@ -218,7 +216,6 @@ import {
218216import { useThreadRunningTerminalIds } from "../state/terminalSessions";
219217import { stackedThreadToast, toastManager } from "./ui/toast";
220218import { Button } from "./ui/button";
221import { Input } from "./ui/input";
222219import {
223220 Combobox,
224221 ComboboxEmpty,
......@@ -229,8 +226,9 @@ import {
229226 ComboboxTrigger,
230227 useComboboxFilter,
231228} from "./ui/combobox";
232import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
229import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar";
233230import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
231import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
234232import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
235233import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
236234import {
......@@ -2370,21 +2368,19 @@ export default function Sidebar() {
23702368 const projectScopeFilter = useComboboxFilter();
23712369 // Filtering derives from the same React state that controls the input, so
23722370 // the visible query and the visible list can never desync — the peer wiring
2373 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope
2374 // reset, not a searchable entry: it only shows while a project scope is
2375 // active (there is something to reset) and the query is empty, so it can't
2376 // outrank a project match under autoHighlight and no-hit queries reach the
2377 // empty state.
2371 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default
2372 // row, not a searchable entry: it heads the list while the query is empty and
2373 // drops out while filtering, so it can't outrank a project match under
2374 // autoHighlight and no-hit queries reach the empty state.
23782375 const filteredProjectScopeItems = useMemo(
23792376 () =>
23802377 filterSidebarProjectScopeItems({
23812378 items: projectScopeItems,
2382 activeScopeKey: projectScopeKey,
23832379 query: projectScopeMenuState.query,
23842380 matches: (item, query) =>
23852381 projectScopeFilter.contains(item, query, (candidate) => candidate.label),
23862382 }),
2387 [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query],
2383 [projectScopeFilter, projectScopeItems, projectScopeMenuState.query],
23882384 );
23892385 const scopedProjectGroup = useMemo(
23902386 () =>
......@@ -2457,6 +2453,8 @@ export default function Sidebar() {
24572453 },
24582454 [isMobile, router, setOpenMobile],
24592455 );
2456 // Anchor for the scope popup: the header search field, not its icon trigger.
2457 const headerSearchRef = useRef<HTMLDivElement | null>(null);
24602458 // Safari can send a click after Ctrl+click opens settings. Ignore that one
24612459 // selection, then clear the guard when the picker opens again.
24622460 const suppressNextScopeChangeRef = useRef(false);
......@@ -4325,100 +4323,11 @@ export default function Sidebar() {
43254323 fixedHeader={
43264324 // Lifted above the stage backdrop, whose fade bleeds below the
43274325 // header and would otherwise paint across the search row's outline.
4328 <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]">
4329 <div className="flex items-center gap-1">
4330 <div 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">
4331 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
4332 <Input
4333 ref={threadSearchInputRef}
4334 nativeInput
4335 unstyled
4336 type="search"
4337 value={threadSearchQuery}
4338 onChange={(event) => {
4339 setThreadSearchQuery(event.currentTarget.value);
4340 setActiveSearchResultIndex(0);
4341 }}
4342 onKeyDown={handleThreadSearchKeyDown}
4343 placeholder="Search threads or PRs"
4344 aria-label="Search threads"
4345 role="combobox"
4346 aria-autocomplete="list"
4347 aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
4348 aria-controls={
4349 isSearchingThreads && threadSearchResults.length > 0
4350 ? "sidebar-thread-search-results"
4351 : undefined
4352 }
4353 aria-activedescendant={
4354 isSearchingThreads && threadSearchResults[activeSearchResultIndex]
4355 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
4356 : undefined
4357 }
4358 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"
4359 />
4360 {isSearchingThreads ? (
4361 <Button
4362 type="button"
4363 size="icon-micro"
4364 variant="ghost"
4365 className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
4366 aria-label="Clear thread search"
4367 onClick={() => {
4368 clearThreadSearch();
4369 threadSearchInputRef.current?.focus();
4370 }}
4371 >
4372 <XIcon className="size-3" />
4373 </Button>
4374 ) : null}
4375 </div>
4376 <div className="shrink-0">
4377 <Tooltip>
4378 <TooltipTrigger
4379 render={
4380 <SidebarMenuButton
4381 size="icon"
4382 type="button"
4383 className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
4384 onClick={handleNewThreadClick}
4385 disabled={projects.length === 0}
4386 aria-label="New thread"
4387 />
4388 }
4389 >
4390 <SquarePenIcon />
4391 <span
4392 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
4393 aria-hidden="true"
4394 />
4395 </TooltipTrigger>
4396 <TooltipPopup side="right">
4397 {projectGroups.length > 1 ? (
4398 <span className="flex flex-col gap-0.5">
4399 <span>
4400 {newThreadShortcutLabel
4401 ? `New thread (${newThreadShortcutLabel})`
4402 : "New thread"}
4403 </span>
4404 <span className="text-muted-foreground">
4405 New thread in current project: Shift+click
4406 {newThreadInProjectShortcutLabel
4407 ? ` (${newThreadInProjectShortcutLabel})`
4408 : ""}
4409 </span>
4410 </span>
4411 ) : newThreadShortcutLabel ? (
4412 `New thread (${newThreadShortcutLabel})`
4413 ) : (
4414 "New thread"
4415 )}
4416 </TooltipPopup>
4417 </Tooltip>
4418 </div>
4419 </div>
4420 {projectGroups.length > 0 ? (
4421 <div className="flex items-center gap-1">
4326 <SidebarGroup className="relative z-[1] p-[var(--sidebar-content-inset)]">
4327 <SidebarThreadHeader
4328 searchFieldRef={headerSearchRef}
4329 hasProjects={projectGroups.length > 0}
4330 projectScope={
44224331 <Combobox
44234332 items={projectScopeItems}
44244333 filteredItems={filteredProjectScopeItems}
......@@ -4445,34 +4354,33 @@ export default function Sidebar() {
44454354 >
44464355 <ComboboxTrigger
44474356 render={
4448 <SidebarMenuButton
4449 aria-label="Filter threads by project"
4450 className="min-w-0 flex-1 ps-[calc(var(--sidebar-row-content-inset)-1px)] focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
4357 <SidebarHeaderIconButton
4358 label={
4359 scopedProjectGroup
4360 ? `Filter threads by project: ${scopedProjectGroup.displayName}`
4361 : "Filter threads by project"
4362 }
44514363 />
44524364 }
44534365 >
44544366 {scopedProjectGroup ? (
4367 // Wrapped so the button's direct-child svg color rule cannot override
4368 // a project's own icon color.
44554369 <span className="flex shrink-0">
44564370 <ProjectFavicon project={scopedProjectGroup} className="size-4" />
44574371 </span>
44584372 ) : (
4459 <FolderIcon className="size-4 shrink-0" />
4373 <FolderIcon className="size-4" />
44604374 )}
4461 <span className="min-w-0 flex-1 truncate">
4462 {scopedProjectGroup?.displayName ?? "All projects"}
4463 </span>
4464 {scopedProjectGroup && showProjectEnvironments ? (
4465 <ProjectEnvironmentBadge
4466 group={scopedProjectGroup}
4467 primaryEnvironmentId={primaryEnvironmentId}
4468 machineByEnvironmentId={environmentMachineById}
4469 />
4470 ) : null}
4471 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
44724375 </ComboboxTrigger>
44734376 <ComboboxPopup
44744377 align="start"
4475 className="w-(--anchor-width) min-w-0 overflow-hidden"
4378 // Anchored to the search field, not the 28px trigger: the
4379 // popup opens under the field, is at least as wide as it,
4380 // and grows to fit project names up to a cap, past which
4381 // the rows truncate.
4382 anchor={headerSearchRef}
4383 className="max-w-[min(18rem,var(--available-width))] overflow-hidden"
44764384 >
44774385 <ComboboxSearchInput
44784386 aria-label="Search projects"
......@@ -4552,28 +4460,25 @@ export default function Sidebar() {
45524460 </ComboboxList>
45534461 </ComboboxPopup>
45544462 </Combobox>
4555 <Tooltip>
4556 <TooltipTrigger
4557 render={
4558 <SidebarMenuButton
4559 size="icon"
4560 className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
4561 onClick={openAddProjectCommandPalette}
4562 type="button"
4563 aria-label="New project"
4564 />
4565 }
4566 >
4567 <FolderPlusIcon />
4568 <span
4569 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
4570 aria-hidden="true"
4571 />
4572 </TooltipTrigger>
4573 <TooltipPopup side="right">New project</TooltipPopup>
4574 </Tooltip>
4575 </div>
4576 ) : null}
4463 }
4464 onNewProject={openAddProjectCommandPalette}
4465 onNewThread={handleNewThreadClick}
4466 newThreadDisabled={projects.length === 0}
4467 newThreadShortcutLabel={newThreadShortcutLabel}
4468 newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
4469 showNewThreadInProjectHint={projectGroups.length > 1}
4470 searchInputRef={threadSearchInputRef}
4471 searchQuery={threadSearchQuery}
4472 onSearchQueryChange={(value) => {
4473 setThreadSearchQuery(value);
4474 setActiveSearchResultIndex(0);
4475 }}
4476 onSearchKeyDown={handleThreadSearchKeyDown}
4477 isSearching={isSearchingThreads}
4478 searchResultCount={threadSearchResults.length}
4479 activeSearchResultIndex={activeSearchResultIndex}
4480 onClearSearch={clearThreadSearch}
4481 />
45774482 </SidebarGroup>
45784483 }
45794484 >
apps/web/src/components/sidebar/SidebarThreadHeader.tsx created+208
......@@ -0,0 +1,208 @@
1/**
2 * The sidebar header: one row holding search, project scope and new thread.
3 *
4 * Search owns the row's text and spans it. Project scope collapses to an icon
5 * that sits with new-project and new-thread as a segmented group at the end.
6 * The scope icon swaps to the project favicon while a project is selected,
7 * so the header still names the scope after the row that showed it is gone.
8 *
9 * The scope picker itself is passed in: its combobox state lives with the rest
10 * of the sidebar's scope logic. `searchFieldRef` lands on the search field so
11 * the picker's popup can anchor to that width rather than to its 28px trigger.
12 */
13import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react";
14import {
15 type ComponentProps,
16 type KeyboardEvent as ReactKeyboardEvent,
17 type MouseEvent as ReactMouseEvent,
18 type ReactNode,
19 type RefObject,
20} from "react";
21
22import { cn } from "~/lib/utils";
23import { Button } from "../ui/button";
24import { Input } from "../ui/input";
25import { SidebarMenuButton } from "../ui/sidebar";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27
28export interface SidebarThreadHeaderProps {
29 /** Lands on the search field so a popup can anchor to its width. */
30 searchFieldRef?: RefObject<HTMLDivElement | null>;
31 /** Without projects there is nothing to scope, so those controls stay out. */
32 hasProjects: boolean;
33 /** The project scope combobox, rendered as the first icon of the group. */
34 projectScope: ReactNode;
35 onNewProject: () => void;
36 /** Receives the click so Shift+click can skip the project picker. */
37 onNewThread: (event: ReactMouseEvent) => void;
38 newThreadDisabled: boolean;
39 newThreadShortcutLabel: string | null | undefined;
40 newThreadInProjectShortcutLabel: string | null | undefined;
41 /** Shift+click only matters once there is more than one project to pick. */
42 showNewThreadInProjectHint: boolean;
43 searchInputRef: RefObject<HTMLInputElement | null>;
44 searchQuery: string;
45 onSearchQueryChange: (value: string) => void;
46 onSearchKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void;
47 isSearching: boolean;
48 searchResultCount: number;
49 activeSearchResultIndex: number;
50 onClearSearch: () => void;
51}
52
53export function SidebarThreadHeader({
54 searchFieldRef,
55 hasProjects,
56 projectScope,
57 onNewProject,
58 onNewThread,
59 newThreadDisabled,
60 newThreadShortcutLabel,
61 newThreadInProjectShortcutLabel,
62 showNewThreadInProjectHint,
63 searchInputRef,
64 searchQuery,
65 onSearchQueryChange,
66 onSearchKeyDown,
67 isSearching,
68 searchResultCount,
69 activeSearchResultIndex,
70 onClearSearch,
71}: SidebarThreadHeaderProps) {
72 const resultsVisible = isSearching && searchResultCount > 0;
73 // Results shrink as the query narrows, so the active index can outrun the
74 // list; pointing aria-activedescendant at a removed option strands the
75 // screen reader on nothing.
76 const activeResultExists = resultsVisible && activeSearchResultIndex < searchResultCount;
77 const newThreadLabel = newThreadShortcutLabel
78 ? `New thread (${newThreadShortcutLabel})`
79 : "New thread";
80
81 return (
82 <div className="flex items-center gap-1">
83 <div
84 ref={searchFieldRef}
85 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"
86 >
87 <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" />
88 <Input
89 ref={searchInputRef}
90 nativeInput
91 unstyled
92 type="search"
93 value={searchQuery}
94 onChange={(event) => onSearchQueryChange(event.currentTarget.value)}
95 onKeyDown={onSearchKeyDown}
96 placeholder="Search"
97 aria-label="Search threads"
98 role="combobox"
99 aria-autocomplete="list"
100 aria-expanded={resultsVisible}
101 aria-controls={resultsVisible ? "sidebar-thread-search-results" : undefined}
102 aria-activedescendant={
103 activeResultExists
104 ? `sidebar-thread-search-result-${activeSearchResultIndex}`
105 : undefined
106 }
107 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)]"
108 />
109 {isSearching ? (
110 <Button
111 type="button"
112 size="icon-micro"
113 variant="ghost"
114 className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
115 aria-label="Clear thread search"
116 onClick={() => {
117 onClearSearch();
118 searchInputRef.current?.focus();
119 }}
120 >
121 <XIcon className="size-3" />
122 </Button>
123 ) : null}
124 </div>
125 {/* Segmented well: the icons read as one control instead of three loose
126 buttons competing with the search field beside them. */}
127 <div className="flex shrink-0 items-center rounded-md bg-sidebar-control-surface/60 p-px">
128 {hasProjects ? (
129 <>
130 {projectScope}
131 <SidebarHeaderIconButton label="New project" onClick={onNewProject}>
132 <FolderPlusIcon />
133 </SidebarHeaderIconButton>
134 </>
135 ) : null}
136 <SidebarHeaderIconButton
137 label="New thread"
138 tooltip={
139 showNewThreadInProjectHint ? (
140 <span className="flex flex-col gap-0.5">
141 <span>{newThreadLabel}</span>
142 <span className="text-muted-foreground">
143 New thread in current project: Shift+click
144 {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""}
145 </span>
146 </span>
147 ) : (
148 newThreadLabel
149 )
150 }
151 disabled={newThreadDisabled}
152 onClick={onNewThread}
153 >
154 <SquarePenIcon />
155 </SidebarHeaderIconButton>
156 </div>
157 </div>
158 );
159}
160
161/**
162 * Icon button with a tooltip, sized for the header's segmented pair. Spreads
163 * unknown props through so it can serve as a popup trigger's render target,
164 * which injects its own handlers, ref and aria state.
165 */
166export function SidebarHeaderIconButton({
167 label,
168 tooltip = label,
169 className,
170 children,
171 ...rest
172}: {
173 /** Accessible name; also the tooltip unless `tooltip` says more. */
174 label: string;
175 tooltip?: ReactNode;
176 className?: string | undefined;
177 children?: ReactNode;
178} & Omit<
179 ComponentProps<typeof SidebarMenuButton>,
180 "children" | "className" | "tooltip" | "isActive" | "aria-label"
181>) {
182 return (
183 <Tooltip>
184 <TooltipTrigger
185 render={
186 <SidebarMenuButton
187 size="icon"
188 type="button"
189 aria-label={label}
190 {...rest}
191 className={cn(
192 "relative size-7 shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar",
193 className,
194 )}
195 />
196 }
197 >
198 {children}
199 {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */}
200 <span
201 aria-hidden
202 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
203 />
204 </TooltipTrigger>
205 <TooltipPopup side="top">{tooltip}</TooltipPopup>
206 </Tooltip>
207 );
208}