diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 1348e6561049..9d1eb6bcd196 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,9 +6,11 @@ import { buildBulkTitleRegenerationContextMenuItem, buildMultiSelectThreadContextMenuItems, createThreadJumpHintVisibilityController, + filterSidebarProjectScopeItems, getSidebarThreadIdsToPrewarm, getVisibleSidebarThreadIds, resolveAdjacentThreadId, + reduceSidebarProjectScopeMenuState, getFallbackThreadIdAfterDelete, getVisibleThreadsForProject, getProjectSortTimestamp, @@ -783,6 +785,69 @@ describe("searchSidebarThreadsByTitle", () => { }); }); +describe("filterSidebarProjectScopeItems", () => { + const items = [ + { value: "all", label: "All projects" }, + { value: "alpha", label: "Alpha workspace" }, + { value: "beta", label: "Beta tools" }, + ] as const; + const filter = (activeScopeKey: string | null, 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 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("returns matching projects in source order and supports no-match results", () => { + expect(filter(null, "WORK")).toEqual([items[1]]); + expect(filter(null, "missing")).toEqual([]); + }); +}); + +describe("reduceSidebarProjectScopeMenuState", () => { + const queriedOpenState = { open: true, query: "alpha" }; + + it("clears the query when the combobox closes through onOpenChange", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "open-changed", + open: false, + }), + ).toEqual({ open: false, query: "" }); + }); + + it("clears the query when project settings closes the combobox", () => { + expect( + reduceSidebarProjectScopeMenuState(queriedOpenState, { + type: "project-settings-opened", + }), + ).toEqual({ open: false, query: "" }); + }); + + it("keeps the popup open while the query changes", () => { + expect( + reduceSidebarProjectScopeMenuState( + { open: true, query: "" }, + { type: "query-changed", query: "beta" }, + ), + ).toEqual({ open: true, query: "beta" }); + }); +}); + describe("sortThreadsForSidebar", () => { const sortable = (input: { id: string; createdAt: string }) => ({ id: input.id, diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 07fb4bc8ab59..7e42a8f8e638 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -583,6 +583,44 @@ export function searchSidebarThreadsByTitle thread.title.toLowerCase().includes(normalizedQuery)); } +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; +} + +export interface SidebarProjectScopeMenuState { + readonly open: boolean; + readonly query: string; +} + +export type SidebarProjectScopeMenuAction = + | { readonly type: "query-changed"; readonly query: string } + | { readonly type: "open-changed"; readonly open: boolean } + | { readonly type: "project-settings-opened" }; + +export function reduceSidebarProjectScopeMenuState( + state: SidebarProjectScopeMenuState, + action: SidebarProjectScopeMenuAction, +): SidebarProjectScopeMenuState { + switch (action.type) { + case "query-changed": + return { ...state, query: action.query }; + case "open-changed": + return { open: action.open, query: "" }; + case "project-settings-opened": + return { open: false, query: "" }; + } +} + type SettledTimestampInput = Pick< SidebarThreadSummary, "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 63681a54813a..ee96b407db75 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -60,6 +60,7 @@ import { useCallback, useEffect, useMemo, + useReducer, useRef, useState, type KeyboardEvent as ReactKeyboardEvent, @@ -125,6 +126,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; import { animatePinnedLayoutChanges, buildBulkTitleRegenerationContextMenuItem, + filterSidebarProjectScopeItems, formatWorkingDurationLabel, firstValidTimestampMs, hasUnseenCompletion, @@ -132,6 +134,7 @@ import { isTrailingDoubleClick, orderItemsByPreferredIds, planPinnedReorder, + reduceSidebarProjectScopeMenuState, resolveAdjacentThreadId, resolveSettledTimestamp, resolveSidebarThreadStatus, @@ -177,7 +180,16 @@ 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, + ComboboxTrigger, + useComboboxFilter, +} from "./ui/combobox"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; @@ -1803,7 +1815,6 @@ export default function Sidebar() { ); }, }); - const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -1941,6 +1952,51 @@ 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 combobox selection contract + // while the popup search filters the same collection. + 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], + ); + const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer( + reduceSidebarProjectScopeMenuState, + { open: false, query: "" }, + ); + 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. + 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], + ); const scopedProjectGroup = useMemo( () => projectScopeKey === null @@ -2000,7 +2056,7 @@ export default function Sidebar() { (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { event.preventDefault(); event.stopPropagation(); - setProjectScopeMenuOpen(false); + dispatchProjectScopeMenu({ type: "project-settings-opened" }); if (isMobile) { setOpenMobile(false); } @@ -3490,8 +3546,23 @@ export default function Sidebar() { {projectGroups.length > 0 ? (
- - item.label} + isItemEqualToValue={(a, b) => a.value === b.value} + open={projectScopeMenuState.open} + onOpenChange={(open) => { + dispatchProjectScopeMenu({ type: "open-changed", open }); + }} + value={selectedProjectScopeItem} + onValueChange={(item) => { + if (!item) return; + setProjectScopeKey(item.value === "all" ? null : item.value); + }} + > + - - - - setProjectScopeKey(value === "all" ? null : (value as string)) - } - > - - - All projects - - {projectGroups.map((project) => { - const scopeKey = project.projectKey; + + +
+
+
+
+ No matching projects. + + {(item: (typeof projectScopeItems)[number]) => { + const project = projectGroupByScopeKey.get(item.value) ?? null; return ( - - - {project.displayName} - - + {project ? ( + + ) : ( + + )} + {item.label} + {project ? ( + + ) : null} + ); - })} -
-
-
+ }} + + +