From 64fe5a921d6d4a5ee8aa048d431b5aa03e736030 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 18:16:14 -0700 Subject: [PATCH 1/9] feat(web): make the sidebar project filter a searchable combobox --- apps/web/src/components/Sidebar.tsx | 186 +++++++++++++++++----------- 1 file changed, 116 insertions(+), 70 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..53592cd8d677 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -177,7 +177,14 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button } from "./ui/button"; import { Input } from "./ui/input"; -import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { + Combobox, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, + ComboboxPopup, +} from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; @@ -1941,6 +1948,31 @@ export default function Sidebar() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); + // {value, label} items let Base UI drive the whole combobox contract: the + // input displays the selection's label and typing filters against it. + const projectScopeItems = useMemo( + () => [ + { value: "all", label: "All projects" }, + ...projectGroups.map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), + ], + [projectGroups], + ); + const projectGroupByScopeKey = useMemo( + () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)), + [projectGroups], + ); + const selectedProjectScopeItem = useMemo( + () => + projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ?? + projectScopeItems[0]!, + [projectScopeItems, projectScopeKey], + ); + // Popup anchor: the whole row, not the inner input element, so the list + // lines up with the control the user sees. + const projectScopeAnchorRef = useRef(null); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -3490,80 +3522,94 @@ export default function Sidebar() { {projectGroups.length > 0 ? (
- - - } + item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuOpen} + onOpenChange={setProjectScopeMenuOpen} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > +
- {scopedProjectGroup ? ( - - ) : ( - - )} - - {scopedProjectGroup?.displayName ?? "All projects"} - - - - - - setProjectScopeKey(value === "all" ? null : (value as string)) + + ) : ( + + ) } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; + className="w-full [&_input]:ps-9!" + inputClassName="h-8 w-full rounded-md bg-transparent text-sm font-medium text-sidebar-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-sidebar-muted-foreground" + /> +
+ + No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; return ( - - - {project.displayName} - - + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + ); - })} - - -
+ }} + + + Date: Sun, 9 Aug 2026 19:04:40 -0700 Subject: [PATCH 2/9] fix(web): hide the All projects reset row while filtering --- apps/web/src/components/Sidebar.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 53592cd8d677..7ccd8a5f52ca 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ import { ComboboxItem, ComboboxList, ComboboxPopup, + useComboboxFilter, } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; @@ -1973,6 +1974,7 @@ export default function Sidebar() { // Popup anchor: the whole row, not the inner input element, so the list // lines up with the control the user sees. const projectScopeAnchorRef = useRef(null); + const projectScopeFilter = useComboboxFilter(); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -3526,6 +3528,16 @@ export default function Sidebar() { items={projectScopeItems} autoHighlight openOnInputClick + // "All projects" is a scope reset, not a searchable entry: + // hide it while filtering so it can't outrank a project + // match under autoHighlight, and so no-hit queries reach + // the empty state. Same convention as DiffPanel's + // "Automatic" row. + filter={(item, query, itemToString) => + item.value === "all" + ? query.trim().length === 0 + : projectScopeFilter.contains(item, query, itemToString) + } itemToStringLabel={(item) => item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={projectScopeMenuOpen} From 6ae13553241356369c77aeda60d918e2d3eb40b7 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:30:08 -0700 Subject: [PATCH 3/9] fix(web): match the project combobox size to the old menu trigger --- apps/web/src/components/Sidebar.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 7ccd8a5f52ca..79a019e54c9e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3569,7 +3569,7 @@ export default function Sidebar() { ) } - className="w-full [&_input]:ps-9!" + className="w-full [&_input]:h-8 [&_input]:ps-9!" inputClassName="h-8 w-full rounded-md bg-transparent text-sm font-medium text-sidebar-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-sidebar-muted-foreground" />
@@ -3579,7 +3579,7 @@ export default function Sidebar() { className="w-(--anchor-width)" > No matching projects. - + {(item: (typeof projectScopeItems)[number]) => { const project = projectGroupByScopeKey.get(item.value) ?? null; return ( From b87f45a9ea395709ddf00eaa524d2f0606d63058 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:42:55 -0700 Subject: [PATCH 4/9] feat(web): move project search into the scope popup --- apps/web/src/components/Sidebar.tsx | 82 +++++++++++++++++------------ 1 file changed, 48 insertions(+), 34 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 79a019e54c9e..2bc58e603943 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -184,6 +184,7 @@ import { ComboboxItem, ComboboxList, ComboboxPopup, + ComboboxTrigger, useComboboxFilter, } from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; @@ -1971,9 +1972,7 @@ export default function Sidebar() { projectScopeItems[0]!, [projectScopeItems, projectScopeKey], ); - // Popup anchor: the whole row, not the inner input element, so the list - // lines up with the control the user sees. - const projectScopeAnchorRef = useRef(null); + const [projectScopeQuery, setProjectScopeQuery] = useState(""); const projectScopeFilter = useComboboxFilter(); const scopedProjectGroup = useMemo( () => @@ -3527,7 +3526,6 @@ export default function Sidebar() { item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={projectScopeMenuOpen} - onOpenChange={setProjectScopeMenuOpen} + onOpenChange={(open) => { + setProjectScopeMenuOpen(open); + if (!open) setProjectScopeQuery(""); + }} value={selectedProjectScopeItem} onValueChange={(item) => { if (!item) return; setProjectScopeKey(item.value === "all" ? null : item.value); }} > -
- - ) : ( - - ) - } - className="w-full [&_input]:h-8 [&_input]:ps-9!" - inputClassName="h-8 w-full rounded-md bg-transparent text-sm font-medium text-sidebar-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-sidebar-muted-foreground" - /> -
- + } > + {scopedProjectGroup ? ( + + ) : ( + + )} + + {scopedProjectGroup?.displayName ?? "All projects"} + + + + +
+
+
+
No matching projects. {(item: (typeof projectScopeItems)[number]) => { From c8462f86ed5a93c19a520ccd72dccc8f9fa9ccd1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 19:56:08 -0700 Subject: [PATCH 5/9] fix(web): derive project scope filtering from the query state --- apps/web/src/components/Sidebar.tsx | 31 +++++++++++++++++++---------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 2bc58e603943..5ab03d1392da 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1974,6 +1974,21 @@ export default function Sidebar() { ); const [projectScopeQuery, setProjectScopeQuery] = useState(""); 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 the query is empty, so + // it can't outrank a project match under autoHighlight and no-hit queries + // reach the empty state. + const filteredProjectScopeItems = useMemo(() => { + const query = projectScopeQuery.trim(); + if (query.length === 0) return projectScopeItems; + return projectScopeItems.filter( + (item) => + item.value !== "all" && + projectScopeFilter.contains(item, query, (candidate) => candidate.label), + ); + }, [projectScopeFilter, projectScopeItems, projectScopeQuery]); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -2033,7 +2048,10 @@ export default function Sidebar() { (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); + // Programmatic close skips onOpenChange, so drop the query here too — + // a stale query must not survive into the next open. setProjectScopeMenuOpen(false); + setProjectScopeQuery(""); if (isMobile) { setOpenMobile(false); } @@ -3525,23 +3543,14 @@ export default function Sidebar() {
- item.value === "all" - ? query.trim().length === 0 - : projectScopeFilter.contains(item, query, itemToString) - } itemToStringLabel={(item) => item.label} isItemEqualToValue={(a, b) => a.value === b.value} open={projectScopeMenuOpen} onOpenChange={(open) => { setProjectScopeMenuOpen(open); - if (!open) setProjectScopeQuery(""); + setProjectScopeQuery(""); }} value={selectedProjectScopeItem} onValueChange={(item) => { From a5bfbc1cfa98e57c6299de10a55571a519d9bb88 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:04:06 -0700 Subject: [PATCH 6/9] feat(web): only offer the All projects reset while a scope is active --- apps/web/src/components/Sidebar.tsx | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 5ab03d1392da..315d1079bb54 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1977,18 +1977,20 @@ export default function Sidebar() { // 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 the query is empty, so - // it can't outrank a project match under autoHighlight and no-hit queries - // reach the empty state. + // 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. const filteredProjectScopeItems = useMemo(() => { const query = projectScopeQuery.trim(); - if (query.length === 0) return projectScopeItems; - return projectScopeItems.filter( - (item) => - item.value !== "all" && + const projectItems = projectScopeItems.filter((item) => item.value !== "all"); + if (query.length > 0) { + return projectItems.filter((item) => projectScopeFilter.contains(item, query, (candidate) => candidate.label), - ); - }, [projectScopeFilter, projectScopeItems, projectScopeQuery]); + ); + } + return projectScopeKey === null ? projectItems : projectScopeItems; + }, [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeQuery]); const scopedProjectGroup = useMemo( () => projectScopeKey === null From dbcc43072bf373a534051515315724df799020d3 Mon Sep 17 00:00:00 2001 From: Dara Adedeji <76637177+SunkenInTime@users.noreply.github.com> Date: Sun, 9 Aug 2026 22:23:58 -0700 Subject: [PATCH 7/9] style(web): give the project search box a filled secondary background --- apps/web/src/components/Sidebar.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 315d1079bb54..b1f81b8fb895 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -3584,16 +3584,16 @@ export default function Sidebar() { -
-
+
+