Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 8 additions & 12 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -828,30 +828,26 @@ describe("filterSidebarProjectScopeItems", () => {
{ value: "alpha", label: "Alpha workspace" },
{ value: "beta", label: "Beta tools" },
] as const;
const filter = (activeScopeKey: string | null, query: string) =>
const filter = (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 default row first while the query is empty", () => {
expect(filter("")).toEqual(items);
expect(filter(" ")).toEqual(items);
});

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("hides the default row while filtering", () => {
expect(filter("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([]);
expect(filter("WORK")).toEqual([items[1]]);
expect(filter("missing")).toEqual([]);
});
});

Expand Down
8 changes: 2 additions & 6 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,16 +894,12 @@ export function searchSidebarThreads<

export function filterSidebarProjectScopeItems<TItem extends { readonly value: string }>(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;
if (query.length === 0) return input.items;
return input.items.filter((item) => item.value !== "all" && input.matches(item, query));
}

export interface SidebarProjectScopeMenuState {
Expand Down
191 changes: 48 additions & 143 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,10 @@ import {
CircleDashedIcon,
ClockIcon,
FolderIcon,
FolderPlusIcon,
GitBranchIcon,
PinIcon,
PinOffIcon,
PlusIcon,
SearchIcon,
SettingsIcon,
SquarePenIcon,
TerminalIcon,
Expand Down Expand Up @@ -217,7 +215,6 @@ import {
import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import {
Combobox,
ComboboxEmpty,
Expand All @@ -228,8 +225,9 @@ import {
ComboboxTrigger,
useComboboxFilter,
} from "./ui/combobox";
import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
import { SidebarContent, SidebarGroup, useSidebar } from "./ui/sidebar";
import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { SidebarHeaderIconButton, SidebarThreadHeader } from "./sidebar/SidebarThreadHeader";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip";
import {
Expand Down Expand Up @@ -2367,21 +2365,19 @@ export default function Sidebar() {
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.
// in DiffPanel and BranchToolbarBranchSelector. "All projects" is the default
// row, not a searchable entry: it heads the list while the query is empty and
// drops out while filtering, 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],
[projectScopeFilter, projectScopeItems, projectScopeMenuState.query],
);
const scopedProjectGroup = useMemo(
() =>
Expand Down Expand Up @@ -2454,6 +2450,8 @@ export default function Sidebar() {
},
[isMobile, router, setOpenMobile],
);
// Anchor for the scope popup: the header search field, not its icon trigger.
const headerSearchRef = useRef<HTMLDivElement | null>(null);
// Safari can send a click after Ctrl+click opens settings. Ignore that one
// selection, then clear the guard when the picker opens again.
const suppressNextScopeChangeRef = useRef(false);
Expand Down Expand Up @@ -4322,100 +4320,11 @@ export default function Sidebar() {
fixedHeader={
// Lifted above the stage backdrop, whose fade bleeds below the
// header and would otherwise paint across the search row's outline.
<SidebarGroup className="relative z-[1] gap-1 p-[var(--sidebar-content-inset)]">
<div className="flex items-center gap-1">
<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">
<SearchIcon className="size-4 shrink-0 text-sidebar-muted-foreground/80" />
<Input
ref={threadSearchInputRef}
nativeInput
unstyled
type="search"
value={threadSearchQuery}
onChange={(event) => {
setThreadSearchQuery(event.currentTarget.value);
setActiveSearchResultIndex(0);
}}
onKeyDown={handleThreadSearchKeyDown}
placeholder="Search threads or PRs"
aria-label="Search threads"
role="combobox"
aria-autocomplete="list"
aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
aria-controls={
isSearchingThreads && threadSearchResults.length > 0
? "sidebar-thread-search-results"
: undefined
}
aria-activedescendant={
isSearchingThreads && threadSearchResults[activeSearchResultIndex]
? `sidebar-thread-search-result-${activeSearchResultIndex}`
: undefined
}
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"
/>
{isSearchingThreads ? (
<Button
type="button"
size="icon-micro"
variant="ghost"
className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
aria-label="Clear thread search"
onClick={() => {
clearThreadSearch();
threadSearchInputRef.current?.focus();
}}
>
<XIcon className="size-3" />
</Button>
) : null}
</div>
<div className="shrink-0">
<Tooltip>
<TooltipTrigger
render={
<SidebarMenuButton
size="icon"
type="button"
className="relative focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
onClick={handleNewThreadClick}
disabled={projects.length === 0}
aria-label="New thread"
/>
}
>
<SquarePenIcon />
<span
className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipPopup side="right">
{projectGroups.length > 1 ? (
<span className="flex flex-col gap-0.5">
<span>
{newThreadShortcutLabel
? `New thread (${newThreadShortcutLabel})`
: "New thread"}
</span>
<span className="text-muted-foreground">
New thread in current project: Shift+click
{newThreadInProjectShortcutLabel
? ` (${newThreadInProjectShortcutLabel})`
: ""}
</span>
</span>
) : newThreadShortcutLabel ? (
`New thread (${newThreadShortcutLabel})`
) : (
"New thread"
)}
</TooltipPopup>
</Tooltip>
</div>
</div>
{projectGroups.length > 0 ? (
<div className="flex items-center gap-1">
<SidebarGroup className="relative z-[1] p-[var(--sidebar-content-inset)]">
<SidebarThreadHeader
searchFieldRef={headerSearchRef}
hasProjects={projectGroups.length > 0}
projectScope={
<Combobox
items={projectScopeItems}
filteredItems={filteredProjectScopeItems}
Expand All @@ -4442,34 +4351,33 @@ export default function Sidebar() {
>
<ComboboxTrigger
render={
<SidebarMenuButton
aria-label="Filter threads by project"
className="min-w-0 flex-1 ps-[calc(var(--sidebar-row-content-inset)-1px)] focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
<SidebarHeaderIconButton
label={
scopedProjectGroup
? `Filter threads by project: ${scopedProjectGroup.displayName}`
: "Filter threads by project"
}
/>
}
>
{scopedProjectGroup ? (
// Wrapped so the button's direct-child svg color rule cannot override
// a project's own icon color.
<span className="flex shrink-0">
<ProjectFavicon project={scopedProjectGroup} className="size-4" />
</span>
) : (
<FolderIcon className="size-4 shrink-0" />
<FolderIcon className="size-4" />
)}
<span className="min-w-0 flex-1 truncate">
{scopedProjectGroup?.displayName ?? "All projects"}
</span>
{scopedProjectGroup && showProjectEnvironments ? (
<ProjectEnvironmentBadge
group={scopedProjectGroup}
primaryEnvironmentId={primaryEnvironmentId}
machineByEnvironmentId={environmentMachineById}
/>
) : null}
<ChevronDownIcon className="-mr-px size-4 shrink-0" />
</ComboboxTrigger>
<ComboboxPopup
align="start"
className="w-(--anchor-width) min-w-0 overflow-hidden"
// Anchored to the search field, not the 28px trigger: the
// popup opens under the field, is at least as wide as it,
// and grows to fit project names up to a cap, past which
// the rows truncate.
anchor={headerSearchRef}
className="max-w-[min(18rem,var(--available-width))] overflow-hidden"
>
<ComboboxSearchInput
aria-label="Search projects"
Expand Down Expand Up @@ -4549,28 +4457,25 @@ export default function Sidebar() {
</ComboboxList>
</ComboboxPopup>
</Combobox>
<Tooltip>
<TooltipTrigger
render={
<SidebarMenuButton
size="icon"
className="relative shrink-0 focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar"
onClick={openAddProjectCommandPalette}
type="button"
aria-label="New project"
/>
}
>
<FolderPlusIcon />
<span
className="pointer-events-none absolute left-1/2 top-1/2 size-[max(100%,3rem)] -translate-1/2 pointer-fine:hidden"
aria-hidden="true"
/>
</TooltipTrigger>
<TooltipPopup side="right">New project</TooltipPopup>
</Tooltip>
</div>
) : null}
}
onNewProject={openAddProjectCommandPalette}
onNewThread={handleNewThreadClick}
newThreadDisabled={projects.length === 0}
newThreadShortcutLabel={newThreadShortcutLabel}
newThreadInProjectShortcutLabel={newThreadInProjectShortcutLabel}
showNewThreadInProjectHint={projectGroups.length > 1}
searchInputRef={threadSearchInputRef}
searchQuery={threadSearchQuery}
onSearchQueryChange={(value) => {
setThreadSearchQuery(value);
setActiveSearchResultIndex(0);
}}
onSearchKeyDown={handleThreadSearchKeyDown}
isSearching={isSearchingThreads}
searchResultCount={threadSearchResults.length}
activeSearchResultIndex={activeSearchResultIndex}
onClearSearch={clearThreadSearch}
/>
</SidebarGroup>
}
>
Expand Down
Loading
Loading