| ... | @@ -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"; |
| 125 | import { | 126 | import { |
| 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"; |
| 177 | import { stackedThreadToast, toastManager } from "./ui/toast"; | 180 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 178 | import { Button } from "./ui/button"; | 181 | import { Button } from "./ui/button"; |
| 179 | import { Input } from "./ui/input"; | 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 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; | 193 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; |
| 182 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; | 194 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 183 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; | 195 | import { 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 without | 1952 | // 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 === null | 2002 | 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 | <MenuTrigger | 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 | render={ | 3566 | render={ |
| 3496 | <SidebarMenuButton | 3567 | <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 | <MenuRadioGroup | 3589 | <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 | <MenuRadioItem | 3595 | <ComboboxInput |
| 3525 | value="all" | 3596 | aria-label="Search projects" |
| 3526 | closeOnClick | 3597 | 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 | <MenuRadioItem | 3618 | <ComboboxItem |
| 3536 | key={scopeKey} | 3619 | key={item.value} |
| 3537 | value={scopeKey} | 3620 | hideIndicator |
| 3538 | closeOnClick | 3621 | 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 | <ProjectFavicon | 3625 | {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 | <Button | 3632 | ) : ( |
| 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 | <TooltipTrigger | 3658 | <TooltipTrigger |
| 3569 | render={ | 3659 | render={ |