| author | |
| committer | |
| log | d1d15c67f4a5fb82fd8d5e01e5e3b288296789c3 |
| tree | 6e05b852b4f72385eea97d165b87a159d7dad470 |
| parent | b1e223e2b0d87124883b1410ab52dd6a1338e40d |
| signature | Signed by PGP key B5690EEEBB952194 |
4 files changed, 266 insertions(+), 161 deletions(-)
apps/web/src/components/Sidebar.logic.test.ts+8-12| ... | ... | @@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => { |
| 828 | 828 | { value: "alpha", label: "Alpha workspace" }, |
| 829 | 829 | { value: "beta", label: "Beta tools" }, |
| 830 | 830 | ] as const; |
| 831 | const filter = (activeScopeKey: string | null, query: string) => | |
| 831 | const filter = (query: string) => | |
| 832 | 832 | filterSidebarProjectScopeItems({ |
| 833 | 833 | items, |
| 834 | activeScopeKey, | |
| 835 | 834 | query, |
| 836 | 835 | matches: (item, candidate) => |
| 837 | 836 | item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()), |
| 838 | 837 | }); |
| 839 | 838 | |
| 840 | it("omits the reset row when the sidebar is already unscoped", () => { | |
| 841 | expect(filter(null, "")).toEqual(items.slice(1)); | |
| 839 | it("shows the default row first while the query is empty", () => { | |
| 840 | expect(filter("")).toEqual(items); | |
| 841 | expect(filter(" ")).toEqual(items); | |
| 842 | 842 | }); |
| 843 | 843 | |
| 844 | it("shows the reset row first while a project scope is active", () => { | |
| 845 | expect(filter("alpha", "")).toEqual(items); | |
| 846 | }); | |
| 847 | ||
| 848 | it("hides the reset row while filtering an active scope", () => { | |
| 849 | expect(filter("alpha", "all")).toEqual([]); | |
| 844 | it("hides the default row while filtering", () => { | |
| 845 | expect(filter("all")).toEqual([]); | |
| 850 | 846 | }); |
| 851 | 847 | |
| 852 | 848 | it("returns matching projects in source order and supports no-match results", () => { |
| 853 | expect(filter(null, "WORK")).toEqual([items[1]]); | |
| 854 | expect(filter(null, "missing")).toEqual([]); | |
| 849 | expect(filter("WORK")).toEqual([items[1]]); | |
| 850 | expect(filter("missing")).toEqual([]); | |
| 855 | 851 | }); |
| 856 | 852 | }); |
| 857 | 853 |
apps/web/src/components/Sidebar.logic.ts+2-6| ... | ... | @@ -894,16 +894,12 @@ export function searchSidebarThreads< |
| 894 | 894 | |
| 895 | 895 | export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: { |
| 896 | 896 | items: readonly TItem[]; |
| 897 | activeScopeKey: string | null; | |
| 898 | 897 | query: string; |
| 899 | 898 | matches: (item: TItem, query: string) => boolean; |
| 900 | 899 | }): readonly TItem[] { |
| 901 | const projectItems = input.items.filter((item) => item.value !== "all"); | |
| 902 | 900 | const query = input.query.trim(); |
| 903 | if (query.length > 0) { | |
| 904 | return projectItems.filter((item) => input.matches(item, query)); | |
| 905 | } | |
| 906 | return input.activeScopeKey === null ? projectItems : input.items; | |
| 901 | if (query.length === 0) return input.items; | |
| 902 | return input.items.filter((item) => item.value !== "all" && input.matches(item, query)); | |
| 907 | 903 | } |
| 908 | 904 | |
| 909 | 905 | export interface SidebarProjectScopeMenuState { |
apps/web/src/components/Sidebar.tsx+48-143| ... | ... | @@ -46,12 +46,10 @@ import { |
| 46 | 46 | CircleDashedIcon, |
| 47 | 47 | ClockIcon, |
| 48 | 48 | FolderIcon, |
| 49 | FolderPlusIcon, | |
| 50 | 49 | GitBranchIcon, |
| 51 | 50 | PinIcon, |
| 52 | 51 | PinOffIcon, |
| 53 | 52 | PlusIcon, |
| 54 | SearchIcon, | |
| 55 | 53 | SettingsIcon, |
| 56 | 54 | SquarePenIcon, |
| 57 | 55 | TerminalIcon, |
| ... | ... | @@ -218,7 +216,6 @@ import { |
| 218 | 216 | import { useThreadRunningTerminalIds } from "../state/terminalSessions"; |
| 219 | 217 | import { stackedThreadToast, toastManager } from "./ui/toast"; |
| 220 | 218 | import { Button } from "./ui/button"; |
| 221 | import { Input } from "./ui/input"; | |
| 222 | 219 | import { |
| 223 | 220 | Combobox, |
| 224 | 221 | ComboboxEmpty, |
| ... | ... | @@ -229,8 +226,9 @@ import { |
| 229 | 226 | ComboboxTrigger, |
| 230 | 227 | useComboboxFilter, |
| 231 | 228 | } from "./ui/combobox"; |
| 232 | import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; | |
| 229 | import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar"; | |
| 233 | 230 | import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; |
| 231 | import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader"; | |
| 234 | 232 | import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover"; |
| 235 | 233 | import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; |
| 236 | 234 | import { |
| ... | ... | @@ -2370,21 +2368,19 @@ export default function Sidebar() { |
| 2370 | 2368 | const projectScopeFilter = useComboboxFilter(); |
| 2371 | 2369 | // Filtering derives from the same React state that controls the input, so |
| 2372 | 2370 | // the visible query and the visible list can never desync — the peer wiring |
| 2373 | // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope | |
| 2374 | // reset, not a searchable entry: it only shows while a project scope is | |
| 2375 | // active (there is something to reset) and the query is empty, so it can't | |
| 2376 | // outrank a project match under autoHighlight and no-hit queries reach the | |
| 2377 | // empty state. | |
| 2371 | // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default | |
| 2372 | // row, not a searchable entry: it heads the list while the query is empty and | |
| 2373 | // drops out while filtering, so it can't outrank a project match under | |
| 2374 | // autoHighlight and no-hit queries reach the empty state. | |
| 2378 | 2375 | const filteredProjectScopeItems = useMemo( |
| 2379 | 2376 | () => |
| 2380 | 2377 | filterSidebarProjectScopeItems({ |
| 2381 | 2378 | items: projectScopeItems, |
| 2382 | activeScopeKey: projectScopeKey, | |
| 2383 | 2379 | query: projectScopeMenuState.query, |
| 2384 | 2380 | matches: (item, query) => |
| 2385 | 2381 | projectScopeFilter.contains(item, query, (candidate) => candidate.label), |
| 2386 | 2382 | }), |
| 2387 | [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query], | |
| 2383 | [projectScopeFilter, projectScopeItems, projectScopeMenuState.query], | |
| 2388 | 2384 | ); |
| 2389 | 2385 | const scopedProjectGroup = useMemo( |
| 2390 | 2386 | () => |
| ... | ... | @@ -2457,6 +2453,8 @@ export default function Sidebar() { |
| 2457 | 2453 | }, |
| 2458 | 2454 | [isMobile, router, setOpenMobile], |
| 2459 | 2455 | ); |
| 2456 | // Anchor for the scope popup: the header search field, not its icon trigger. | |
| 2457 | const headerSearchRef = useRef<HTMLDivElement | null>(null); | |
| 2460 | 2458 | // Safari can send a click after Ctrl+click opens settings. Ignore that one |
| 2461 | 2459 | // selection, then clear the guard when the picker opens again. |
| 2462 | 2460 | const suppressNextScopeChangeRef = useRef(false); |
| ... | ... | @@ -4325,100 +4323,11 @@ export default function Sidebar() { |
| 4325 | 4323 | fixedHeader={ |
| 4326 | 4324 | // Lifted above the stage backdrop, whose fade bleeds below the |
| 4327 | 4325 | // header and would otherwise paint across the search row's outline. |
| 4328 | <SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]"> | |
| 4329 | <div className="flex items-center gap-1"> | |
| 4330 | <div className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground"> | |
| 4331 | <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" /> | |
| 4332 | <Input | |
| 4333 | ref={threadSearchInputRef} | |
| 4334 | nativeInput | |
| 4335 | unstyled | |
| 4336 | type="search" | |
| 4337 | value={threadSearchQuery} | |
| 4338 | onChange={(event) => { | |
| 4339 | setThreadSearchQuery(event.currentTarget.value); | |
| 4340 | setActiveSearchResultIndex(0); | |
| 4341 | }} | |
| 4342 | onKeyDown={handleThreadSearchKeyDown} | |
| 4343 | placeholder="Search threads or PRs" | |
| 4344 | aria-label="Search threads" | |
| 4345 | role="combobox" | |
| 4346 | aria-autocomplete="list" | |
| 4347 | aria-expanded={isSearchingThreads && threadSearchResults.length > 0} | |
| 4348 | aria-controls={ | |
| 4349 | isSearchingThreads && threadSearchResults.length > 0 | |
| 4350 | ? "sidebar-thread-search-results" | |
| 4351 | : undefined | |
| 4352 | } | |
| 4353 | aria-activedescendant={ | |
| 4354 | isSearchingThreads && threadSearchResults[activeSearchResultIndex] | |
| 4355 | ? `sidebar-thread-search-result-${activeSearchResultIndex}` | |
| 4356 | : undefined | |
| 4357 | } | |
| 4358 | className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-sidebar-muted-foreground" | |
| 4359 | /> | |
| 4360 | {isSearchingThreads ? ( | |
| 4361 | <Button | |
| 4362 | type="button" | |
| 4363 | size="icon-micro" | |
| 4364 | variant="ghost" | |
| 4365 | className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground" | |
| 4366 | aria-label="Clear thread search" | |
| 4367 | onClick={() => { | |
| 4368 | clearThreadSearch(); | |
| 4369 | threadSearchInputRef.current?.focus(); | |
| 4370 | }} | |
| 4371 | > | |
| 4372 | <XIcon className="size-3" /> | |
| 4373 | </Button> | |
| 4374 | ) : null} | |
| 4375 | </div> | |
| 4376 | <div className="shrink-0"> | |
| 4377 | <Tooltip> | |
| 4378 | <TooltipTrigger | |
| 4379 | render={ | |
| 4380 | <SidebarMenuButton | |
| 4381 | size="icon" | |
| 4382 | type="button" | |
| 4383 | className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" | |
| 4384 | onClick={handleNewThreadClick} | |
| 4385 | disabled={projects.length === 0} | |
| 4386 | aria-label="New thread" | |
| 4387 | /> | |
| 4388 | } | |
| 4389 | > | |
| 4390 | <SquarePenIcon /> | |
| 4391 | <span | |
| 4392 | className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" | |
| 4393 | aria-hidden="true" | |
| 4394 | /> | |
| 4395 | </TooltipTrigger> | |
| 4396 | <TooltipPopup side="right"> | |
| 4397 | {projectGroups.length > 1 ? ( | |
| 4398 | <span className="flex flex-col gap-0.5"> | |
| 4399 | <span> | |
| 4400 | {newThreadShortcutLabel | |
| 4401 | ? `New thread (${newThreadShortcutLabel})` | |
| 4402 | : "New thread"} | |
| 4403 | </span> | |
| 4404 | <span className="text-muted-foreground"> | |
| 4405 | New thread in current project: Shift+click | |
| 4406 | {newThreadInProjectShortcutLabel | |
| 4407 | ? ` (${newThreadInProjectShortcutLabel})` | |
| 4408 | : ""} | |
| 4409 | </span> | |
| 4410 | </span> | |
| 4411 | ) : newThreadShortcutLabel ? ( | |
| 4412 | `New thread (${newThreadShortcutLabel})` | |
| 4413 | ) : ( | |
| 4414 | "New thread" | |
| 4415 | )} | |
| 4416 | </TooltipPopup> | |
| 4417 | </Tooltip> | |
| 4418 | </div> | |
| 4419 | </div> | |
| 4420 | {projectGroups.length > 0 ? ( | |
| 4421 | <div className="flex items-center gap-1"> | |
| 4326 | <SidebarGroup className="relative z-[1] p-[var(--sidebar-content-inset)]"> | |
| 4327 | <SidebarThreadHeader | |
| 4328 | searchFieldRef={headerSearchRef} | |
| 4329 | hasProjects={projectGroups.length > 0} | |
| 4330 | projectScope={ | |
| 4422 | 4331 | <Combobox |
| 4423 | 4332 | items={projectScopeItems} |
| 4424 | 4333 | filteredItems={filteredProjectScopeItems} |
| ... | ... | @@ -4445,34 +4354,33 @@ export default function Sidebar() { |
| 4445 | 4354 | > |
| 4446 | 4355 | <ComboboxTrigger |
| 4447 | 4356 | render={ |
| 4448 | <SidebarMenuButton | |
| 4449 | aria-label="Filter threads by project" | |
| 4450 | className="min-w-0 flex-1 ps-[calc(var(--sidebar-row-content-inset)-1px)] focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" | |
| 4357 | <SidebarHeaderIconButton | |
| 4358 | label={ | |
| 4359 | scopedProjectGroup | |
| 4360 | ? `Filter threads by project: ${scopedProjectGroup.displayName}` | |
| 4361 | : "Filter threads by project" | |
| 4362 | } | |
| 4451 | 4363 | /> |
| 4452 | 4364 | } |
| 4453 | 4365 | > |
| 4454 | 4366 | {scopedProjectGroup ? ( |
| 4367 | // Wrapped so the button's direct-child svg color rule cannot override | |
| 4368 | // a project's own icon color. | |
| 4455 | 4369 | <span className="flex shrink-0"> |
| 4456 | 4370 | <ProjectFavicon project={scopedProjectGroup} className="size-4" /> |
| 4457 | 4371 | </span> |
| 4458 | 4372 | ) : ( |
| 4459 | <FolderIcon className="size-4 shrink-0" /> | |
| 4373 | <FolderIcon className="size-4" /> | |
| 4460 | 4374 | )} |
| 4461 | <span className="min-w-0 flex-1 truncate"> | |
| 4462 | {scopedProjectGroup?.displayName ?? "All projects"} | |
| 4463 | </span> | |
| 4464 | {scopedProjectGroup && showProjectEnvironments ? ( | |
| 4465 | <ProjectEnvironmentBadge | |
| 4466 | group={scopedProjectGroup} | |
| 4467 | primaryEnvironmentId={primaryEnvironmentId} | |
| 4468 | machineByEnvironmentId={environmentMachineById} | |
| 4469 | /> | |
| 4470 | ) : null} | |
| 4471 | <ChevronDownIcon className="-mr-px size-4 shrink-0" /> | |
| 4472 | 4375 | </ComboboxTrigger> |
| 4473 | 4376 | <ComboboxPopup |
| 4474 | 4377 | align="start" |
| 4475 | className="w-(--anchor-width) min-w-0 overflow-hidden" | |
| 4378 | // Anchored to the search field, not the 28px trigger: the | |
| 4379 | // popup opens under the field, is at least as wide as it, | |
| 4380 | // and grows to fit project names up to a cap, past which | |
| 4381 | // the rows truncate. | |
| 4382 | anchor={headerSearchRef} | |
| 4383 | className="max-w-[min(18rem,var(--available-width))] overflow-hidden" | |
| 4476 | 4384 | > |
| 4477 | 4385 | <ComboboxSearchInput |
| 4478 | 4386 | aria-label="Search projects" |
| ... | ... | @@ -4552,28 +4460,25 @@ export default function Sidebar() { |
| 4552 | 4460 | </ComboboxList> |
| 4553 | 4461 | </ComboboxPopup> |
| 4554 | 4462 | </Combobox> |
| 4555 | <Tooltip> | |
| 4556 | <TooltipTrigger | |
| 4557 | render={ | |
| 4558 | <SidebarMenuButton | |
| 4559 | size="icon" | |
| 4560 | className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar" | |
| 4561 | onClick={openAddProjectCommandPalette} | |
| 4562 | type="button" | |
| 4563 | aria-label="New project" | |
| 4564 | /> | |
| 4565 | } | |
| 4566 | > | |
| 4567 | <FolderPlusIcon /> | |
| 4568 | <span | |
| 4569 | className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" | |
| 4570 | aria-hidden="true" | |
| 4571 | /> | |
| 4572 | </TooltipTrigger> | |
| 4573 | <TooltipPopup side="right">New project</TooltipPopup> | |
| 4574 | </Tooltip> | |
| 4575 | </div> | |
| 4576 | ) : null} | |
| 4463 | } | |
| 4464 | onNewProject={openAddProjectCommandPalette} | |
| 4465 | onNewThread={handleNewThreadClick} | |
| 4466 | newThreadDisabled={projects.length === 0} | |
| 4467 | newThreadShortcutLabel={newThreadShortcutLabel} | |
| 4468 | newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel} | |
| 4469 | showNewThreadInProjectHint={projectGroups.length > 1} | |
| 4470 | searchInputRef={threadSearchInputRef} | |
| 4471 | searchQuery={threadSearchQuery} | |
| 4472 | onSearchQueryChange={(value) => { | |
| 4473 | setThreadSearchQuery(value); | |
| 4474 | setActiveSearchResultIndex(0); | |
| 4475 | }} | |
| 4476 | onSearchKeyDown={handleThreadSearchKeyDown} | |
| 4477 | isSearching={isSearchingThreads} | |
| 4478 | searchResultCount={threadSearchResults.length} | |
| 4479 | activeSearchResultIndex={activeSearchResultIndex} | |
| 4480 | onClearSearch={clearThreadSearch} | |
| 4481 | /> | |
| 4577 | 4482 | </SidebarGroup> |
| 4578 | 4483 | } |
| 4579 | 4484 | > |
apps/web/src/components/sidebar/SidebarThreadHeader.tsx created+208| ... | ... | @@ -0,0 +1,208 @@ |
| 1 | /** | |
| 2 | * The sidebar header: one row holding search, project scope and new thread. | |
| 3 | * | |
| 4 | * Search owns the row's text and spans it. Project scope collapses to an icon | |
| 5 | * that sits with new-project and new-thread as a segmented group at the end. | |
| 6 | * The scope icon swaps to the project favicon while a project is selected, | |
| 7 | * so the header still names the scope after the row that showed it is gone. | |
| 8 | * | |
| 9 | * The scope picker itself is passed in: its combobox state lives with the rest | |
| 10 | * of the sidebar's scope logic. `searchFieldRef` lands on the search field so | |
| 11 | * the picker's popup can anchor to that width rather than to its 28px trigger. | |
| 12 | */ | |
| 13 | import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react"; | |
| 14 | import { | |
| 15 | type ComponentProps, | |
| 16 | type KeyboardEvent as ReactKeyboardEvent, | |
| 17 | type MouseEvent as ReactMouseEvent, | |
| 18 | type ReactNode, | |
| 19 | type RefObject, | |
| 20 | } from "react"; | |
| 21 | ||
| 22 | import { cn } from "~/lib/utils"; | |
| 23 | import { Button } from "../ui/button"; | |
| 24 | import { Input } from "../ui/input"; | |
| 25 | import { SidebarMenuButton } from "../ui/sidebar"; | |
| 26 | import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; | |
| 27 | ||
| 28 | export interface SidebarThreadHeaderProps { | |
| 29 | /** Lands on the search field so a popup can anchor to its width. */ | |
| 30 | searchFieldRef?: RefObject<HTMLDivElement | null>; | |
| 31 | /** Without projects there is nothing to scope, so those controls stay out. */ | |
| 32 | hasProjects: boolean; | |
| 33 | /** The project scope combobox, rendered as the first icon of the group. */ | |
| 34 | projectScope: ReactNode; | |
| 35 | onNewProject: () => void; | |
| 36 | /** Receives the click so Shift+click can skip the project picker. */ | |
| 37 | onNewThread: (event: ReactMouseEvent) => void; | |
| 38 | newThreadDisabled: boolean; | |
| 39 | newThreadShortcutLabel: string | null | undefined; | |
| 40 | newThreadInProjectShortcutLabel: string | null | undefined; | |
| 41 | /** Shift+click only matters once there is more than one project to pick. */ | |
| 42 | showNewThreadInProjectHint: boolean; | |
| 43 | searchInputRef: RefObject<HTMLInputElement | null>; | |
| 44 | searchQuery: string; | |
| 45 | onSearchQueryChange: (value: string) => void; | |
| 46 | onSearchKeyDown: (event: ReactKeyboardEvent<HTMLInputElement>) => void; | |
| 47 | isSearching: boolean; | |
| 48 | searchResultCount: number; | |
| 49 | activeSearchResultIndex: number; | |
| 50 | onClearSearch: () => void; | |
| 51 | } | |
| 52 | ||
| 53 | export function SidebarThreadHeader({ | |
| 54 | searchFieldRef, | |
| 55 | hasProjects, | |
| 56 | projectScope, | |
| 57 | onNewProject, | |
| 58 | onNewThread, | |
| 59 | newThreadDisabled, | |
| 60 | newThreadShortcutLabel, | |
| 61 | newThreadInProjectShortcutLabel, | |
| 62 | showNewThreadInProjectHint, | |
| 63 | searchInputRef, | |
| 64 | searchQuery, | |
| 65 | onSearchQueryChange, | |
| 66 | onSearchKeyDown, | |
| 67 | isSearching, | |
| 68 | searchResultCount, | |
| 69 | activeSearchResultIndex, | |
| 70 | onClearSearch, | |
| 71 | }: SidebarThreadHeaderProps) { | |
| 72 | const resultsVisible = isSearching && searchResultCount > 0; | |
| 73 | // Results shrink as the query narrows, so the active index can outrun the | |
| 74 | // list; pointing aria-activedescendant at a removed option strands the | |
| 75 | // screen reader on nothing. | |
| 76 | const activeResultExists = resultsVisible && activeSearchResultIndex < searchResultCount; | |
| 77 | const newThreadLabel = newThreadShortcutLabel | |
| 78 | ? `New thread (${newThreadShortcutLabel})` | |
| 79 | : "New thread"; | |
| 80 | ||
| 81 | return ( | |
| 82 | <div className="flex items-center gap-1"> | |
| 83 | <div | |
| 84 | ref={searchFieldRef} | |
| 85 | className="flex h-8 min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-sm font-medium text-sidebar-muted-foreground hover:bg-sidebar-row-hover hover:text-sidebar-foreground" | |
| 86 | > | |
| 87 | <SearchIcon className="size-4 shrink-0 text-[var(--sidebar-icon-color)]" /> | |
| 88 | <Input | |
| 89 | ref={searchInputRef} | |
| 90 | nativeInput | |
| 91 | unstyled | |
| 92 | type="search" | |
| 93 | value={searchQuery} | |
| 94 | onChange={(event) => onSearchQueryChange(event.currentTarget.value)} | |
| 95 | onKeyDown={onSearchKeyDown} | |
| 96 | placeholder="Search" | |
| 97 | aria-label="Search threads" | |
| 98 | role="combobox" | |
| 99 | aria-autocomplete="list" | |
| 100 | aria-expanded={resultsVisible} | |
| 101 | aria-controls={resultsVisible ? "sidebar-thread-search-results" : undefined} | |
| 102 | aria-activedescendant={ | |
| 103 | activeResultExists | |
| 104 | ? `sidebar-thread-search-result-${activeSearchResultIndex}` | |
| 105 | : undefined | |
| 106 | } | |
| 107 | className="min-w-0 flex-1 [&_[data-slot=input]]:h-auto [&_[data-slot=input]]:p-0 [&_[data-slot=input]]:leading-normal [&_[data-slot=input]]:text-sm [&_[data-slot=input]]:font-medium [&_[data-slot=input]]:text-sidebar-foreground [&_[data-slot=input]]:placeholder:text-[var(--sidebar-icon-color)]" | |
| 108 | /> | |
| 109 | {isSearching ? ( | |
| 110 | <Button | |
| 111 | type="button" | |
| 112 | size="icon-micro" | |
| 113 | variant="ghost" | |
| 114 | className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground" | |
| 115 | aria-label="Clear thread search" | |
| 116 | onClick={() => { | |
| 117 | onClearSearch(); | |
| 118 | searchInputRef.current?.focus(); | |
| 119 | }} | |
| 120 | > | |
| 121 | <XIcon className="size-3" /> | |
| 122 | </Button> | |
| 123 | ) : null} | |
| 124 | </div> | |
| 125 | {/* Segmented well: the icons read as one control instead of three loose | |
| 126 | buttons competing with the search field beside them. */} | |
| 127 | <div className="flex shrink-0 items-center rounded-md bg-sidebar-control-surface/60 p-px"> | |
| 128 | {hasProjects ? ( | |
| 129 | <> | |
| 130 | {projectScope} | |
| 131 | <SidebarHeaderIconButton label="New project" onClick={onNewProject}> | |
| 132 | <FolderPlusIcon /> | |
| 133 | </SidebarHeaderIconButton> | |
| 134 | </> | |
| 135 | ) : null} | |
| 136 | <SidebarHeaderIconButton | |
| 137 | label="New thread" | |
| 138 | tooltip={ | |
| 139 | showNewThreadInProjectHint ? ( | |
| 140 | <span className="flex flex-col gap-0.5"> | |
| 141 | <span>{newThreadLabel}</span> | |
| 142 | <span className="text-muted-foreground"> | |
| 143 | New thread in current project: Shift+click | |
| 144 | {newThreadInProjectShortcutLabel ? ` (${newThreadInProjectShortcutLabel})` : ""} | |
| 145 | </span> | |
| 146 | </span> | |
| 147 | ) : ( | |
| 148 | newThreadLabel | |
| 149 | ) | |
| 150 | } | |
| 151 | disabled={newThreadDisabled} | |
| 152 | onClick={onNewThread} | |
| 153 | > | |
| 154 | <SquarePenIcon /> | |
| 155 | </SidebarHeaderIconButton> | |
| 156 | </div> | |
| 157 | </div> | |
| 158 | ); | |
| 159 | } | |
| 160 | ||
| 161 | /** | |
| 162 | * Icon button with a tooltip, sized for the header's segmented pair. Spreads | |
| 163 | * unknown props through so it can serve as a popup trigger's render target, | |
| 164 | * which injects its own handlers, ref and aria state. | |
| 165 | */ | |
| 166 | export function SidebarHeaderIconButton({ | |
| 167 | label, | |
| 168 | tooltip = label, | |
| 169 | className, | |
| 170 | children, | |
| 171 | ...rest | |
| 172 | }: { | |
| 173 | /** Accessible name; also the tooltip unless `tooltip` says more. */ | |
| 174 | label: string; | |
| 175 | tooltip?: ReactNode; | |
| 176 | className?: string | undefined; | |
| 177 | children?: ReactNode; | |
| 178 | } & Omit< | |
| 179 | ComponentProps<typeof SidebarMenuButton>, | |
| 180 | "children" | "className" | "tooltip" | "isActive" | "aria-label" | |
| 181 | >) { | |
| 182 | return ( | |
| 183 | <Tooltip> | |
| 184 | <TooltipTrigger | |
| 185 | render={ | |
| 186 | <SidebarMenuButton | |
| 187 | size="icon" | |
| 188 | type="button" | |
| 189 | aria-label={label} | |
| 190 | {...rest} | |
| 191 | className={cn( | |
| 192 | "relative size-7 shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar", | |
| 193 | className, | |
| 194 | )} | |
| 195 | /> | |
| 196 | } | |
| 197 | > | |
| 198 | {children} | |
| 199 | {/* Coarse-pointer hit area, matching the rest of the sidebar chrome. */} | |
| 200 | <span | |
| 201 | aria-hidden | |
| 202 | className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden" | |
| 203 | /> | |
| 204 | </TooltipTrigger> | |
| 205 | <TooltipPopup side="top">{tooltip}</TooltipPopup> | |
| 206 | </Tooltip> | |
| 207 | ); | |
| 208 | } |