| ... | ... | @@ -60,6 +60,7 @@ import { |
| 60 | 60 | useCallback, |
| 61 | 61 | useEffect, |
| 62 | 62 | useMemo, |
| 63 | useReducer, |
| 63 | 64 | useRef, |
| 64 | 65 | useState, |
| 65 | 66 | type KeyboardEvent as ReactKeyboardEvent, |
| ... | ... | @@ -125,6 +126,7 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic"; |
| 125 | 126 | import { |
| 126 | 127 | animatePinnedLayoutChanges, |
| 127 | 128 | buildBulkTitleRegenerationContextMenuItem, |
| 129 | filterSidebarProjectScopeItems, |
| 128 | 130 | formatWorkingDurationLabel, |
| 129 | 131 | firstValidTimestampMs, |
| 130 | 132 | hasUnseenCompletion, |
| ... | ... | @@ -132,6 +134,7 @@ import { |
| 132 | 134 | isTrailingDoubleClick, |
| 133 | 135 | orderItemsByPreferredIds, |
| 134 | 136 | planPinnedReorder, |
| 137 | reduceSidebarProjectScopeMenuState, |
| 135 | 138 | resolveAdjacentThreadId, |
| 136 | 139 | resolveSettledTimestamp, |
| 137 | 140 | resolveSidebarThreadStatus, |
| ... | ... | @@ -177,7 +180,16 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 177 | 180 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 178 | 181 | import { Button } from "./ui/button"; |
| 179 | 182 | import { Input } from "./ui/input"; |
| 180 | | import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; |
| 183 | import { |
| 184 | Combobox, |
| 185 | ComboboxEmpty, |
| 186 | ComboboxInput, |
| 187 | ComboboxItem, |
| 188 | ComboboxList, |
| 189 | ComboboxPopup, |
| 190 | ComboboxTrigger, |
| 191 | useComboboxFilter, |
| 192 | } from "./ui/combobox"; |
| 181 | 193 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 182 | 194 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 183 | 195 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| ... | ... | @@ -1803,7 +1815,6 @@ export default function Sidebar() { |
| 1803 | 1815 | ); |
| 1804 | 1816 | }, |
| 1805 | 1817 | }); |
| 1806 | | const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); |
| 1807 | 1818 | const newThreadContext = useHandleNewThread(); |
| 1808 | 1819 | const openAddProjectCommandPalette = useCallback( |
| 1809 | 1820 | () => openCommandPalette({ open: "add-project" }), |
| ... | ... | @@ -1941,6 +1952,51 @@ export default function Sidebar() { |
| 1941 | 1952 | // Project scope: one menu above the list. Scoping filters the list without |
| 1942 | 1953 | // making the header width depend on the number or length of project names. |
| 1943 | 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 | 2000 | const scopedProjectGroup = useMemo( |
| 1945 | 2001 | () => |
| 1946 | 2002 | projectScopeKey === null |
| ... | ... | @@ -2000,7 +2056,7 @@ export default function Sidebar() { |
| 2000 | 2056 | (event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => { |
| 2001 | 2057 | event.preventDefault(); |
| 2002 | 2058 | event.stopPropagation(); |
| 2003 | | setProjectScopeMenuOpen(false); |
| 2059 | dispatchProjectScopeMenu({ type: "project-settings-opened" }); |
| 2004 | 2060 | if (isMobile) { |
| 2005 | 2061 | setOpenMobile(false); |
| 2006 | 2062 | } |
| ... | ... | @@ -3490,8 +3546,23 @@ export default function Sidebar() { |
| 3490 | 3546 | </div> |
| 3491 | 3547 | {projectGroups.length > 0 ? ( |
| 3492 | 3548 | <div className="flex items-center gap-1"> |
| 3493 | | <Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}> |
| 3494 | | <MenuTrigger |
| 3549 | <Combobox |
| 3550 | 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 | 3566 | render={ |
| 3496 | 3567 | <SidebarMenuButton |
| 3497 | 3568 | aria-label="Filter threads by project" |
| ... | ... | @@ -3513,57 +3584,76 @@ export default function Sidebar() { |
| 3513 | 3584 | {scopedProjectGroup?.displayName ?? "All projects"} |
| 3514 | 3585 | </span> |
| 3515 | 3586 | <ChevronDownIcon className="-mr-px size-4 shrink-0" /> |
| 3516 | | </MenuTrigger> |
| 3517 | | <MenuPopup align="start" className="w-(--anchor-width)"> |
| 3518 | | <MenuRadioGroup |
| 3519 | | value={projectScopeKey ?? "all"} |
| 3520 | | onValueChange={(value) => |
| 3521 | | setProjectScopeKey(value === "all" ? null : (value as string)) |
| 3522 | | } |
| 3523 | | > |
| 3524 | | <MenuRadioItem |
| 3525 | | value="all" |
| 3526 | | closeOnClick |
| 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" |
| 3528 | | > |
| 3529 | | <FolderIcon className="size-4 shrink-0" /> |
| 3530 | | <span className="min-w-0 truncate text-sm">All projects</span> |
| 3531 | | </MenuRadioItem> |
| 3532 | | {projectGroups.map((project) => { |
| 3533 | | const scopeKey = project.projectKey; |
| 3587 | </ComboboxTrigger> |
| 3588 | <ComboboxPopup align="start" className="w-(--anchor-width)"> |
| 3589 | <div className="shrink-0 px-3 pt-2.5"> |
| 3590 | <div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring"> |
| 3591 | <SearchIcon |
| 3592 | aria-hidden="true" |
| 3593 | className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55" |
| 3594 | /> |
| 3595 | <ComboboxInput |
| 3596 | aria-label="Search projects" |
| 3597 | className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5" |
| 3598 | inputClassName="rounded-none bg-transparent text-sm" |
| 3599 | placeholder="Search projects..." |
| 3600 | showTrigger={false} |
| 3601 | size="sm" |
| 3602 | unstyled |
| 3603 | value={projectScopeMenuState.query} |
| 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 | 3617 | return ( |
| 3535 | | <MenuRadioItem |
| 3536 | | key={scopeKey} |
| 3537 | | value={scopeKey} |
| 3538 | | closeOnClick |
| 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" |
| 3618 | <ComboboxItem |
| 3619 | key={item.value} |
| 3620 | hideIndicator |
| 3621 | value={item} |
| 3622 | className="h-8 min-h-8 py-0 font-medium" |
| 3623 | contentClassName="flex min-w-0 items-center gap-2" |
| 3540 | 3624 | > |
| 3541 | | <ProjectFavicon |
| 3542 | | environmentId={project.environmentId} |
| 3543 | | cwd={project.workspaceRoot} |
| 3544 | | faviconPath={project.faviconPath} |
| 3545 | | className="size-4 shrink-0" |
| 3546 | | /> |
| 3547 | | <span className="min-w-0 truncate text-sm">{project.displayName}</span> |
| 3548 | | <Button |
| 3549 | | size="icon-xs" |
| 3550 | | variant="ghost-muted" |
| 3551 | | aria-label={`Project settings for ${project.displayName}`} |
| 3552 | | title={`Project settings for ${project.displayName}`} |
| 3553 | | className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground" |
| 3554 | | onPointerDown={(event) => event.stopPropagation()} |
| 3555 | | onClick={(event) => { |
| 3556 | | void handleProjectSettings(event, project); |
| 3557 | | }} |
| 3558 | | > |
| 3559 | | <SettingsIcon className="size-3.5" /> |
| 3560 | | </Button> |
| 3561 | | </MenuRadioItem> |
| 3625 | {project ? ( |
| 3626 | <ProjectFavicon |
| 3627 | environmentId={project.environmentId} |
| 3628 | cwd={project.workspaceRoot} |
| 3629 | faviconPath={project.faviconPath} |
| 3630 | className="size-4 shrink-0" |
| 3631 | /> |
| 3632 | ) : ( |
| 3633 | <FolderIcon className="size-4 shrink-0" /> |
| 3634 | )} |
| 3635 | <span className="min-w-0 flex-1 truncate text-sm">{item.label}</span> |
| 3636 | {project ? ( |
| 3637 | <Button |
| 3638 | size="icon-xs" |
| 3639 | variant="ghost-muted" |
| 3640 | aria-label={`Project settings for ${project.displayName}`} |
| 3641 | title={`Project settings for ${project.displayName}`} |
| 3642 | className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground" |
| 3643 | onPointerDown={(event) => event.stopPropagation()} |
| 3644 | onClick={(event) => { |
| 3645 | void handleProjectSettings(event, project); |
| 3646 | }} |
| 3647 | > |
| 3648 | <SettingsIcon className="size-3.5" /> |
| 3649 | </Button> |
| 3650 | ) : null} |
| 3651 | </ComboboxItem> |
| 3562 | 3652 | ); |
| 3563 | | })} |
| 3564 | | </MenuRadioGroup> |
| 3565 | | </MenuPopup> |
| 3566 | | </Menu> |
| 3653 | }} |
| 3654 | </ComboboxList> |
| 3655 | </ComboboxPopup> |
| 3656 | </Combobox> |
| 3567 | 3657 | <Tooltip> |
| 3568 | 3658 | <TooltipTrigger |
| 3569 | 3659 | render={ |