authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-12 12:50:34-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-12 12:50:34-03:00
logd1d15c67f4a5fb82fd8d5e01e5e3b288296789c3
tree6e05b852b4f72385eea97d165b87a159d7dad470
parentb1e223e2b0d87124883b1410ab52dd6a1338e40d
signature Signed by PGP key B5690EEEBB952194

feat(sidebar): fold the project scope into the search row (#11315)


4 files changed, 266 insertions(+), 161 deletions(-)

apps/web/src/components/Sidebar.logic.test.ts+8-12
...@@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => {...@@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => {
828 { value: "alpha", label: "Alpha workspace" },828 { value: "alpha", label: "Alpha workspace" },
829 { value: "beta", label: "Beta tools" },829 { value: "beta", label: "Beta tools" },
830 ] as const;830 ] as const;
831 const filter = (activeScopeKey: string | null, query: string) =>831 const filter = (query: string) =>
832 filterSidebarProjectScopeItems({832 filterSidebarProjectScopeItems({
833 items,833 items,
834 activeScopeKey,
835 query,834 query,
836 matches: (item, candidate) =>835 matches: (item, candidate) =>
837 item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),836 item.label.toLocaleLowerCase().includes(candidate.toLocaleLowerCase()),
838 });837 });
839838
840 it("omits the reset row when the sidebar is already unscoped", () => {839 it("shows the default row first while the query is empty", () => {
841 expect(filter(null, "")).toEqual(items.slice(1));840 expect(filter("")).toEqual(items);
841 expect(filter(" ")).toEqual(items);
842 });842 });
843843
844 it("shows the reset row first while a project scope is active", () => {844 it("hides the default row while filtering", () => {
845 expect(filter("alpha", "")).toEqual(items);845 expect(filter("all")).toEqual([]);
846 });
847
848 it("hides the reset row while filtering an active scope", () => {
849 expect(filter("alpha", "all")).toEqual([]);
850 });846 });
851847
852 it("returns matching projects in source order and supports no-match results", () => {848 it("returns matching projects in source order and supports no-match results", () => {
853 expect(filter(null, "WORK")).toEqual([items[1]]);849 expect(filter("WORK")).toEqual([items[1]]);
854 expect(filter(null, "missing")).toEqual([]);850 expect(filter("missing")).toEqual([]);
855 });851 });
856});852});
857853
apps/web/src/components/Sidebar.logic.ts+2-6
...@@ -894,16 +894,12 @@ export function searchSidebarThreads<...@@ -894,16 +894,12 @@ export function searchSidebarThreads<
894894
895export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {895export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(input: {
896 items: readonly TItem[];896 items: readonly TItem[];
897 activeScopeKey: string | null;
898 query: string;897 query: string;
899 matches: (item: TItem, query: string) => boolean;898 matches: (item: TItem, query: string) => boolean;
900}): readonly TItem[] {899}): readonly TItem[] {
901 const projectItems = input.items.filter((item) => item.value !== "all");
902 const query = input.query.trim();900 const query = input.query.trim();
903 if (query.length > 0) {901 if (query.length === 0) return input.items;
904 return projectItems.filter((item) => input.matches(item, query));902 return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
905 }
906 return input.activeScopeKey === null ? projectItems : input.items;
907}903}
908904
909export interface SidebarProjectScopeMenuState {905export interface SidebarProjectScopeMenuState {
apps/web/src/components/Sidebar.tsx+48-143
...@@ -46,12 +46,10 @@ import {...@@ -46,12 +46,10 @@ import {
46 CircleDashedIcon,46 CircleDashedIcon,
47 ClockIcon,47 ClockIcon,
48 FolderIcon,48 FolderIcon,
49 FolderPlusIcon,
50 GitBranchIcon,49 GitBranchIcon,
51 PinIcon,50 PinIcon,
52 PinOffIcon,51 PinOffIcon,
53 PlusIcon,52 PlusIcon,
54 SearchIcon,
55 SettingsIcon,53 SettingsIcon,
56 SquarePenIcon,54 SquarePenIcon,
57 TerminalIcon,55 TerminalIcon,
...@@ -218,7 +216,6 @@ import {...@@ -218,7 +216,6 @@ import {
218import { useThreadRunningTerminalIds } from "../state/terminalSessions";216import { useThreadRunningTerminalIds } from "../state/terminalSessions";
219import { stackedThreadToast, toastManager } from "./ui/toast";217import { stackedThreadToast, toastManager } from "./ui/toast";
220import { Button } from "./ui/button";218import { Button } from "./ui/button";
221import { Input } from "./ui/input";
222import {219import {
223 Combobox,220 Combobox,
224 ComboboxEmpty,221 ComboboxEmpty,
...@@ -229,8 +226,9 @@ import {...@@ -229,8 +226,9 @@ import {
229 ComboboxTrigger,226 ComboboxTrigger,
230 useComboboxFilter,227 useComboboxFilter,
231} from "./ui/combobox";228} from "./ui/combobox";
232import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";229import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar";
233import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";230import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
231import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
234import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";232import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
235import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";233import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
236import {234import {
...@@ -2370,21 +2368,19 @@ export default function Sidebar() {...@@ -2370,21 +2368,19 @@ export default function Sidebar() {
2370 const projectScopeFilter = useComboboxFilter();2368 const projectScopeFilter = useComboboxFilter();
2371 // Filtering derives from the same React state that controls the input, so2369 // Filtering derives from the same React state that controls the input, so
2372 // the visible query and the visible list can never desync — the peer wiring2370 // the visible query and the visible list can never desync — the peer wiring
2373 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is a scope2371 // in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default
2374 // reset, not a searchable entry: it only shows while a project scope is2372 // row, not a searchable entry: it heads the list while the query is empty and
2375 // active (there is something to reset) and the query is empty, so it can't2373 // drops out while filtering, so it can't outrank a project match under
2376 // outrank a project match under autoHighlight and no-hit queries reach the2374 // autoHighlight and no-hit queries reach the empty state.
2377 // empty state.
2378 const filteredProjectScopeItems = useMemo(2375 const filteredProjectScopeItems = useMemo(
2379 () =>2376 () =>
2380 filterSidebarProjectScopeItems({2377 filterSidebarProjectScopeItems({
2381 items: projectScopeItems,2378 items: projectScopeItems,
2382 activeScopeKey: projectScopeKey,
2383 query: projectScopeMenuState.query,2379 query: projectScopeMenuState.query,
2384 matches: (item, query) =>2380 matches: (item, query) =>
2385 projectScopeFilter.contains(item, query, (candidate) => candidate.label),2381 projectScopeFilter.contains(item, query, (candidate) => candidate.label),
2386 }),2382 }),
2387 [projectScopeFilter, projectScopeItems, projectScopeKey, projectScopeMenuState.query],2383 [projectScopeFilter, projectScopeItems, projectScopeMenuState.query],
2388 );2384 );
2389 const scopedProjectGroup = useMemo(2385 const scopedProjectGroup = useMemo(
2390 () =>2386 () =>
...@@ -2457,6 +2453,8 @@ export default function Sidebar() {...@@ -2457,6 +2453,8 @@ export default function Sidebar() {
2457 },2453 },
2458 [isMobile, router, setOpenMobile],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 // Safari can send a click after Ctrl+click opens settings. Ignore that one2458 // Safari can send a click after Ctrl+click opens settings. Ignore that one
2461 // selection, then clear the guard when the picker opens again.2459 // selection, then clear the guard when the picker opens again.
2462 const suppressNextScopeChangeRef = useRef(false);2460 const suppressNextScopeChangeRef = useRef(false);
...@@ -4325,100 +4323,11 @@ export default function Sidebar() {...@@ -4325,100 +4323,11 @@ export default function Sidebar() {
4325 fixedHeader={4323 fixedHeader={
4326 // Lifted above the stage backdrop, whose fade bleeds below the4324 // Lifted above the stage backdrop, whose fade bleeds below the
4327 // header and would otherwise paint across the search row's outline.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)]">4326 <SidebarGroup className="relative z-[1] p-[var(--sidebar-content-inset)]">
4329 <div className="flex items-center gap-1">4327 <SidebarThreadHeader
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">4328 searchFieldRef={headerSearchRef}
4331 <SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />4329 hasProjects={projectGroups.length > 0}
4332 <Input4330 projectScope={
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">
4422 <Combobox4331 <Combobox
4423 items={projectScopeItems}4332 items={projectScopeItems}
4424 filteredItems={filteredProjectScopeItems}4333 filteredItems={filteredProjectScopeItems}
...@@ -4445,34 +4354,33 @@ export default function Sidebar() {...@@ -4445,34 +4354,33 @@ export default function Sidebar() {
4445 >4354 >
4446 <ComboboxTrigger4355 <ComboboxTrigger
4447 render={4356 render={
4448 <SidebarMenuButton4357 <SidebarHeaderIconButton
4449 aria-label="Filter threads by project"4358 label={
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"4359 scopedProjectGroup
4360 ? `Filter threads by project: ${scopedProjectGroup.displayName}`
4361 : "Filter threads by project"
4362 }
4451 />4363 />
4452 }4364 }
4453 >4365 >
4454 {scopedProjectGroup ? (4366 {scopedProjectGroup ? (
4367 // Wrapped so the button's direct-child svg color rule cannot override
4368 // a project's own icon color.
4455 <span className="flex shrink-0">4369 <span className="flex shrink-0">
4456 <ProjectFavicon project={scopedProjectGroup} className="size-4" />4370 <ProjectFavicon project={scopedProjectGroup} className="size-4" />
4457 </span>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 </ComboboxTrigger>4375 </ComboboxTrigger>
4473 <ComboboxPopup4376 <ComboboxPopup
4474 align="start"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 <ComboboxSearchInput4385 <ComboboxSearchInput
4478 aria-label="Search projects"4386 aria-label="Search projects"
...@@ -4552,28 +4460,25 @@ export default function Sidebar() {...@@ -4552,28 +4460,25 @@ export default function Sidebar() {
4552 </ComboboxList>4460 </ComboboxList>
4553 </ComboboxPopup>4461 </ComboboxPopup>
4554 </Combobox>4462 </Combobox>
4555 <Tooltip>4463 }
4556 <TooltipTrigger4464 onNewProject={openAddProjectCommandPalette}
4557 render={4465 onNewThread={handleNewThreadClick}
4558 <SidebarMenuButton4466 newThreadDisabled={projects.length === 0}
4559 size="icon"4467 newThreadShortcutLabel={newThreadShortcutLabel}
4560 className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"4468 newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
4561 onClick={openAddProjectCommandPalette}4469 showNewThreadInProjectHint={projectGroups.length > 1}
4562 type="button"4470 searchInputRef={threadSearchInputRef}
4563 aria-label="New project"4471 searchQuery={threadSearchQuery}
4564 />4472 onSearchQueryChange={(value) => {
4565 }4473 setThreadSearchQuery(value);
4566 >4474 setActiveSearchResultIndex(0);
4567 <FolderPlusIcon />4475 }}
4568 <span4476 onSearchKeyDown={handleThreadSearchKeyDown}
4569 className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"4477 isSearching={isSearchingThreads}
4570 aria-hidden="true"4478 searchResultCount={threadSearchResults.length}
4571 />4479 activeSearchResultIndex={activeSearchResultIndex}
4572 </TooltipTrigger>4480 onClearSearch={clearThreadSearch}
4573 <TooltipPopup side="right">New project</TooltipPopup>4481 />
4574 </Tooltip>
4575 </div>
4576 ) : null}
4577 </SidebarGroup>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 */
13import { FolderPlusIcon, SearchIcon, SquarePenIcon, XIcon } from "lucide-react";
14import {
15 type ComponentProps,
16 type KeyboardEvent as ReactKeyboardEvent,
17 type MouseEvent as ReactMouseEvent,
18 type ReactNode,
19 type RefObject,
20} from "react";
21
22import { cn } from "~/lib/utils";
23import { Button } from "../ui/button";
24import { Input } from "../ui/input";
25import { SidebarMenuButton } from "../ui/sidebar";
26import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
27
28export 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
53export 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 */
166export 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}