authorgravatar for 76637177+SunkenInTime@users.noreply.github.comDara Adedeji <76637177+SunkenInTime@users.noreply.github.com> 2026-08-28 17:15:41-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-28 14:15:41-07:00
log48c176b3cfda7a714c10f18a12d398528ff4c2f9
treef32180378b25ac5e980ea5c561ba6c662f165036
parent94f1948166620c804937f5548a68f5739d76c9fb
signature Signed by PGP key B5690EEEBB952194

feat(web): make the sidebar project filter a searchable combobox (#5931)


3 files changed, 246 insertions(+), 53 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+65
...@@ -6,9 +6,11 @@ import {...@@ -6,9 +6,11 @@ import {
6 buildBulkTitleRegenerationContextMenuItem,6 buildBulkTitleRegenerationContextMenuItem,
7 buildMultiSelectThreadContextMenuItems,7 buildMultiSelectThreadContextMenuItems,
8 createThreadJumpHintVisibilityController,8 createThreadJumpHintVisibilityController,
9 filterSidebarProjectScopeItems,
9 getSidebarThreadIdsToPrewarm,10 getSidebarThreadIdsToPrewarm,
10 getVisibleSidebarThreadIds,11 getVisibleSidebarThreadIds,
11 resolveAdjacentThreadId,12 resolveAdjacentThreadId,
13 reduceSidebarProjectScopeMenuState,
12 getFallbackThreadIdAfterDelete,14 getFallbackThreadIdAfterDelete,
13 getVisibleThreadsForProject,15 getVisibleThreadsForProject,
14 getProjectSortTimestamp,16 getProjectSortTimestamp,
...@@ -783,6 +785,69 @@ describe("searchSidebarThreadsByTitle", () => {...@@ -783,6 +785,69 @@ describe("searchSidebarThreadsByTitle", () => {
783 });785 });
784});786});
785787
788describe("filterSidebarProjectScopeItems", () => {
789 const items = [
790 { value: "all", label: "All projects" },
791 { value: "alpha", label: "Alpha workspace" },
792 { value: "beta", label: "Beta tools" },
793 ] as const;
794 const filter = (activeScopeKey: string | null, query: string) =>
795 filterSidebarProjectScopeItems({
796 items,
797 activeScopeKey,
798 query,
799 matches: (item, candidate) =>
800 item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),
801 });
802
803 it("omits the reset row when the sidebar is already unscoped", () => {
804 expect(filter(null, "")).toEqual(items.slice(1));
805 });
806
807 it("shows the reset row first while a project scope is active", () => {
808 expect(filter("alpha", "")).toEqual(items);
809 });
810
811 it("hides the reset row while filtering an active scope", () => {
812 expect(filter("alpha", "all")).toEqual([]);
813 });
814
815 it("returns matching projects in source order and supports no-match results", () => {
816 expect(filter(null, "WORK")).toEqual([items[1]]);
817 expect(filter(null, "missing")).toEqual([]);
818 });
819});
820
821describe("reduceSidebarProjectScopeMenuState", () => {
822 const queriedOpenState = { open: true, query: "alpha" };
823
824 it("clears the query when the combobox closes through onOpenChange", () => {
825 expect(
826 reduceSidebarProjectScopeMenuState(queriedOpenState, {
827 type: "open-changed",
828 open: false,
829 }),
830 ).toEqual({ open: false, query: "" });
831 });
832
833 it("clears the query when project settings closes the combobox", () => {
834 expect(
835 reduceSidebarProjectScopeMenuState(queriedOpenState, {
836 type: "project-settings-opened",
837 }),
838 ).toEqual({ open: false, query: "" });
839 });
840
841 it("keeps the popup open while the query changes", () => {
842 expect(
843 reduceSidebarProjectScopeMenuState(
844 { open: true, query: "" },
845 { type: "query-changed", query: "beta" },
846 ),
847 ).toEqual({ open: true, query: "beta" });
848 });
849});
850
786describe("sortThreadsForSidebar", () => {851describe("sortThreadsForSidebar", () => {
787 const sortable = (input: { id: string; createdAt: string }) => ({852 const sortable = (input: { id: string; createdAt: string }) => ({
788 id: input.id,853 id: input.id,
apps/web/src/components/Sidebar.logic.ts+38
...@@ -583,6 +583,44 @@ export function searchSidebarThreadsByTitle<T extends { readonly title: string }...@@ -583,6 +583,44 @@ export function searchSidebarThreadsByTitle<T extends { readonly title: string }
583 return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery));583 return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery));
584}584}
585585
586export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
587 items: readonly TItem[];
588 activeScopeKey: string | null;
589 query: string;
590 matches: (item: TItem, query: string) => boolean;
591}): readonly TItem[] {
592 const projectItems = input.items.filter((item) => item.value !== "all");
593 const query = input.query.trim();
594 if (query.length > 0) {
595 return projectItems.filter((item) => input.matches(item, query));
596 }
597 return input.activeScopeKey === null ? projectItems : input.items;
598}
599
600export interface SidebarProjectScopeMenuState {
601 readonly open: boolean;
602 readonly query: string;
603}
604
605export type SidebarProjectScopeMenuAction =
606 | { readonly type: "query-changed"; readonly query: string }
607 | { readonly type: "open-changed"; readonly open: boolean }
608 | { readonly type: "project-settings-opened" };
609
610export function reduceSidebarProjectScopeMenuState(
611 state: SidebarProjectScopeMenuState,
612 action: SidebarProjectScopeMenuAction,
613): SidebarProjectScopeMenuState {
614 switch (action.type) {
615 case "query-changed":
616 return { ...state, query: action.query };
617 case "open-changed":
618 return { open: action.open, query: "" };
619 case "project-settings-opened":
620 return { open: false, query: "" };
621 }
622}
623
586type SettledTimestampInput = Pick<624type SettledTimestampInput = Pick<
587 SidebarThreadSummary,625 SidebarThreadSummary,
588 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"626 "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
apps/web/src/components/Sidebar.tsx+143-53
...@@ -60,6 +60,7 @@ import {...@@ -60,6 +60,7 @@ import {
60 useCallback,60 useCallback,
61 useEffect,61 useEffect,
62 useMemo,62 useMemo,
63 useReducer,
63 useRef,64 useRef,
64 useState,65 useState,
65 type KeyboardEvent as ReactKeyboardEvent,66 type KeyboardEvent as ReactKeyboardEvent,
...@@ -125,6 +126,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";...@@ -125,6 +126,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
125import {126import {
126 animatePinnedLayoutChanges,127 animatePinnedLayoutChanges,
127 buildBulkTitleRegenerationContextMenuItem,128 buildBulkTitleRegenerationContextMenuItem,
129 filterSidebarProjectScopeItems,
128 formatWorkingDurationLabel,130 formatWorkingDurationLabel,
129 firstValidTimestampMs,131 firstValidTimestampMs,
130 hasUnseenCompletion,132 hasUnseenCompletion,
...@@ -132,6 +134,7 @@ import {...@@ -132,6 +134,7 @@ import {
132 isTrailingDoubleClick,134 isTrailingDoubleClick,
133 orderItemsByPreferredIds,135 orderItemsByPreferredIds,
134 planPinnedReorder,136 planPinnedReorder,
137 reduceSidebarProjectScopeMenuState,
135 resolveAdjacentThreadId,138 resolveAdjacentThreadId,
136 resolveSettledTimestamp,139 resolveSettledTimestamp,
137 resolveSidebarThreadStatus,140 resolveSidebarThreadStatus,
...@@ -177,7 +180,16 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";...@@ -177,7 +180,16 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";
177import { stackedThreadToast, toastManager } from "./ui/toast";180import { stackedThreadToast, toastManager } from "./ui/toast";
178import { Button } from "./ui/button";181import { Button } from "./ui/button";
179import { Input } from "./ui/input";182import { Input } from "./ui/input";
180import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";183import {
184 Combobox,
185 ComboboxEmpty,
186 ComboboxInput,
187 ComboboxItem,
188 ComboboxList,
189 ComboboxPopup,
190 ComboboxTrigger,
191 useComboboxFilter,
192} from "./ui/combobox";
181import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";193import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
182import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";194import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
183import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";195import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
...@@ -1803,7 +1815,6 @@ export default function Sidebar() {...@@ -1803,7 +1815,6 @@ export default function Sidebar() {
1803 );1815 );
1804 },1816 },
1805 });1817 });
1806 const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
1807 const newThreadContext = useHandleNewThread();1818 const newThreadContext = useHandleNewThread();
1808 const openAddProjectCommandPalette = useCallback(1819 const openAddProjectCommandPalette = useCallback(
1809 () => openCommandPalette({ open: "add-project" }),1820 () => openCommandPalette({ open: "add-project" }),
...@@ -1941,6 +1952,51 @@ export default function Sidebar() {...@@ -1941,6 +1952,51 @@ export default function Sidebar() {
1941 // Project scope: one menu above the list. Scoping filters the list without1952 // Project scope: one menu above the list. Scoping filters the list without
1942 // making the header width depend on the number or length of project names.1953 // making the header width depend on the number or length of project names.
1943 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);1954 const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
1955 // {value, label} items let Base UI drive the combobox selection contract
1956 // while the popup search filters the same collection.
1957 const projectScopeItems = useMemo(
1958 () => [
1959 { value: "all", label: "All projects" },
1960 ...projectGroups.map((project) => ({
1961 value: project.projectKey,
1962 label: project.displayName,
1963 })),
1964 ],
1965 [projectGroups],
1966 );
1967 const projectGroupByScopeKey = useMemo(
1968 () => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
1969 [projectGroups],
1970 );
1971 const selectedProjectScopeItem = useMemo(
1972 () =>
1973 projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ??
1974 projectScopeItems[0]!,
1975 [projectScopeItems, projectScopeKey],
1976 );
1977 const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer(
1978 reduceSidebarProjectScopeMenuState,
1979 { open: false, query: "" },
1980 );
1981 const projectScopeFilter = useComboboxFilter();
1982 // Filtering derives from the same React state that controls the input, so
1983 // the visible query and the visible list can never desync — the peer wiring
1984 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope
1985 // reset, not a searchable entry: it only shows while a project scope is
1986 // active (there is something to reset) and the query is empty, so it can't
1987 // outrank a project match under autoHighlight and no-hit queries reach the
1988 // empty state.
1989 const filteredProjectScopeItems = useMemo(
1990 () =>
1991 filterSidebarProjectScopeItems({
1992 items: projectScopeItems,
1993 activeScopeKey: projectScopeKey,
1994 query: projectScopeMenuState.query,
1995 matches: (item, query) =>
1996 projectScopeFilter.contains(item, query, (candidate) => candidate.label),
1997 }),
1998 [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query],
1999 );
1944 const scopedProjectGroup = useMemo(2000 const scopedProjectGroup = useMemo(
1945 () =>2001 () =>
1946 projectScopeKey === null2002 projectScopeKey === null
...@@ -2000,7 +2056,7 @@ export default function Sidebar() {...@@ -2000,7 +2056,7 @@ export default function Sidebar() {
2000 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {2056 (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
2001 event.preventDefault();2057 event.preventDefault();
2002 event.stopPropagation();2058 event.stopPropagation();
2003 setProjectScopeMenuOpen(false);2059 dispatchProjectScopeMenu({ type: "project-settings-opened" });
2004 if (isMobile) {2060 if (isMobile) {
2005 setOpenMobile(false);2061 setOpenMobile(false);
2006 }2062 }
...@@ -3490,8 +3546,23 @@ export default function Sidebar() {...@@ -3490,8 +3546,23 @@ export default function Sidebar() {
3490 </div>3546 </div>
3491 {projectGroups.length > 0 ? (3547 {projectGroups.length > 0 ? (
3492 <div className="flex items-center gap-1">3548 <div className="flex items-center gap-1">
3493 <Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}>3549 <Combobox
3494 <MenuTrigger3550 items={projectScopeItems}
3551 filteredItems={filteredProjectScopeItems}
3552 autoHighlight
3553 itemToStringLabel={(item) => item.label}
3554 isItemEqualToValue={(a, b) => a.value === b.value}
3555 open={projectScopeMenuState.open}
3556 onOpenChange={(open) => {
3557 dispatchProjectScopeMenu({ type: "open-changed", open });
3558 }}
3559 value={selectedProjectScopeItem}
3560 onValueChange={(item) => {
3561 if (!item) return;
3562 setProjectScopeKey(item.value === "all" ? null : item.value);
3563 }}
3564 >
3565 <ComboboxTrigger
3495 render={3566 render={
3496 <SidebarMenuButton3567 <SidebarMenuButton
3497 aria-label="Filter threads by project"3568 aria-label="Filter threads by project"
...@@ -3513,57 +3584,76 @@ export default function Sidebar() {...@@ -3513,57 +3584,76 @@ export default function Sidebar() {
3513 {scopedProjectGroup?.displayName ?? "All projects"}3584 {scopedProjectGroup?.displayName ?? "All projects"}
3514 </span>3585 </span>
3515 <ChevronDownIcon className="-mr-px size-4 shrink-0" />3586 <ChevronDownIcon className="-mr-px size-4 shrink-0" />
3516 </MenuTrigger>3587 </ComboboxTrigger>
3517 <MenuPopup align="start" className="w-(--anchor-width)">3588 <ComboboxPopup align="start" className="w-(--anchor-width)">
3518 <MenuRadioGroup3589 <div className="shrink-0 px-3 pt-2.5">
3519 value={projectScopeKey ?? "all"}3590 <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
3520 onValueChange={(value) =>3591 <SearchIcon
3521 setProjectScopeKey(value === "all" ? null : (value as string))3592 aria-hidden="true"
3522 }3593 className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
3523 >3594 />
3524 <MenuRadioItem3595 <ComboboxInput
3525 value="all"3596 aria-label="Search projects"
3526 closeOnClick3597 className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
3527 className="h-8 min-h-8 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"3598 inputClassName="rounded-none bg-transparent text-sm"
3528 >3599 placeholder="Search projects..."
3529 <FolderIcon className="size-4 shrink-0" />3600 showTrigger={false}
3530 <span className="min-w-0 truncate text-sm">All projects</span>3601 size="sm"
3531 </MenuRadioItem>3602 unstyled
3532 {projectGroups.map((project) => {3603 value={projectScopeMenuState.query}
3533 const scopeKey = project.projectKey;3604 onChange={(event) =>
3605 dispatchProjectScopeMenu({
3606 type: "query-changed",
3607 query: event.target.value,
3608 })
3609 }
3610 />
3611 </div>
3612 </div>
3613 <ComboboxEmpty>No matching projects.</ComboboxEmpty>
3614 <ComboboxList>
3615 {(item: (typeof projectScopeItems)[number]) => {
3616 const project = projectGroupByScopeKey.get(item.value) ?? null;
3534 return (3617 return (
3535 <MenuRadioItem3618 <ComboboxItem
3536 key={scopeKey}3619 key={item.value}
3537 value={scopeKey}3620 hideIndicator
3538 closeOnClick3621 value={item}
3539 className="h-8 min-h-8 py-0 text-sm font-medium [&>span:last-child]:flex [&>span:last-child]:min-w-0 [&>span:last-child]:items-center [&>span:last-child]:gap-2"3622 className="h-8 min-h-8 py-0 font-medium"
3623 contentClassName="flex min-w-0 items-center gap-2"
3540 >3624 >
3541 <ProjectFavicon3625 {project ? (
3542 environmentId={project.environmentId}3626 <ProjectFavicon
3543 cwd={project.workspaceRoot}3627 environmentId={project.environmentId}
3544 faviconPath={project.faviconPath}3628 cwd={project.workspaceRoot}
3545 className="size-4 shrink-0"3629 faviconPath={project.faviconPath}
3546 />3630 className="size-4 shrink-0"
3547 <span className="min-w-0 truncate text-sm">{project.displayName}</span>3631 />
3548 <Button3632 ) : (
3549 size="icon-xs"3633 <FolderIcon className="size-4 shrink-0" />
3550 variant="ghost-muted"3634 )}
3551 aria-label={`Project settings for ${project.displayName}`}3635 <span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
3552 title={`Project settings for ${project.displayName}`}3636 {project ? (
3553 className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"3637 <Button
3554 onPointerDown={(event) => event.stopPropagation()}3638 size="icon-xs"
3555 onClick={(event) => {3639 variant="ghost-muted"
3556 void handleProjectSettings(event, project);3640 aria-label={`Project settings for ${project.displayName}`}
3557 }}3641 title={`Project settings for ${project.displayName}`}
3558 >3642 className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"
3559 <SettingsIcon className="size-3.5" />3643 onPointerDown={(event) => event.stopPropagation()}
3560 </Button>3644 onClick={(event) => {
3561 </MenuRadioItem>3645 void handleProjectSettings(event, project);
3646 }}
3647 >
3648 <SettingsIcon className="size-3.5" />
3649 </Button>
3650 ) : null}
3651 </ComboboxItem>
3562 );3652 );
3563 })}3653 }}
3564 </MenuRadioGroup>3654 </ComboboxList>
3565 </MenuPopup>3655 </ComboboxPopup>
3566 </Menu>3656 </Combobox>
3567 <Tooltip>3657 <Tooltip>
3568 <TooltipTrigger3658 <TooltipTrigger
3569 render={3659 render={