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
65 changes: 65 additions & 0 deletions apps/web/src/components/Sidebar.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,11 @@ import {
buildBulkTitleRegenerationContextMenuItem,
buildMultiSelectThreadContextMenuItems,
createThreadJumpHintVisibilityController,
filterSidebarProjectScopeItems,
getSidebarThreadIdsToPrewarm,
getVisibleSidebarThreadIds,
resolveAdjacentThreadId,
reduceSidebarProjectScopeMenuState,
getFallbackThreadIdAfterDelete,
getVisibleThreadsForProject,
getProjectSortTimestamp,
Expand Down Expand Up @@ -783,6 +785,69 @@ describe("searchSidebarThreadsByTitle", () => {
});
});

describe("filterSidebarProjectScopeItems", () => {
const items = [
{ value: "all", label: "All projects" },
{ value: "alpha", label: "Alpha workspace" },
{ value: "beta", label: "Beta tools" },
] as const;
const filter = (activeScopeKey: string | null, 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 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("returns matching projects in source order and supports no-match results", () => {
expect(filter(null, "WORK")).toEqual([items[1]]);
expect(filter(null, "missing")).toEqual([]);
});
});

describe("reduceSidebarProjectScopeMenuState", () => {
const queriedOpenState = { open: true, query: "alpha" };

it("clears the query when the combobox closes through onOpenChange", () => {
expect(
reduceSidebarProjectScopeMenuState(queriedOpenState, {
type: "open-changed",
open: false,
}),
).toEqual({ open: false, query: "" });
});

it("clears the query when project settings closes the combobox", () => {
expect(
reduceSidebarProjectScopeMenuState(queriedOpenState, {
type: "project-settings-opened",
}),
).toEqual({ open: false, query: "" });
});

it("keeps the popup open while the query changes", () => {
expect(
reduceSidebarProjectScopeMenuState(
{ open: true, query: "" },
{ type: "query-changed", query: "beta" },
),
).toEqual({ open: true, query: "beta" });
});
});

describe("sortThreadsForSidebar", () => {
const sortable = (input: { id: string; createdAt: string }) => ({
id: input.id,
Expand Down
38 changes: 38 additions & 0 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -583,6 +583,44 @@ export function searchSidebarThreadsByTitle<T extends { readonly title: string }
return threads.filter((thread) => thread.title.toLowerCase().includes(normalizedQuery));
}

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;
}

export interface SidebarProjectScopeMenuState {
readonly open: boolean;
readonly query: string;
}

export type SidebarProjectScopeMenuAction =
| { readonly type: "query-changed"; readonly query: string }
| { readonly type: "open-changed"; readonly open: boolean }
| { readonly type: "project-settings-opened" };

export function reduceSidebarProjectScopeMenuState(
state: SidebarProjectScopeMenuState,
action: SidebarProjectScopeMenuAction,
): SidebarProjectScopeMenuState {
switch (action.type) {
case "query-changed":
return { ...state, query: action.query };
case "open-changed":
return { open: action.open, query: "" };
case "project-settings-opened":
return { open: false, query: "" };
}
}

type SettledTimestampInput = Pick<
SidebarThreadSummary,
"settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt"
Expand Down
196 changes: 143 additions & 53 deletions apps/web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
useCallback,
useEffect,
useMemo,
useReducer,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
Expand Down Expand Up @@ -125,13 +126,15 @@ import { buildThreadActionMenuItems } from "./threadActionMenu.logic";
import {
animatePinnedLayoutChanges,
buildBulkTitleRegenerationContextMenuItem,
filterSidebarProjectScopeItems,
formatWorkingDurationLabel,
firstValidTimestampMs,
hasUnseenCompletion,
isSidebarNestedLinkClick,
isTrailingDoubleClick,
orderItemsByPreferredIds,
planPinnedReorder,
reduceSidebarProjectScopeMenuState,
resolveAdjacentThreadId,
resolveSettledTimestamp,
resolveSidebarThreadStatus,
Expand Down Expand Up @@ -177,7 +180,16 @@ import { useThreadRunningTerminalIds } from "../state/terminalSessions";
import { stackedThreadToast, toastManager } from "./ui/toast";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu";
import {
Combobox,
ComboboxEmpty,
ComboboxInput,
ComboboxItem,
ComboboxList,
ComboboxPopup,
ComboboxTrigger,
useComboboxFilter,
} from "./ui/combobox";
import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar";
import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome";
import { Popover, PopoverPopup, PopoverTrigger } from "./ui/popover";
Expand Down Expand Up @@ -1803,7 +1815,6 @@ export default function Sidebar() {
);
},
});
const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false);
const newThreadContext = useHandleNewThread();
const openAddProjectCommandPalette = useCallback(
() => openCommandPalette({ open: "add-project" }),
Expand Down Expand Up @@ -1941,6 +1952,51 @@ export default function Sidebar() {
// Project scope: one menu above the list. Scoping filters the list without
// making the header width depend on the number or length of project names.
const [projectScopeKey, setProjectScopeKey] = useState<string | null>(null);
// {value, label} items let Base UI drive the combobox selection contract
// while the popup search filters the same collection.
const projectScopeItems = useMemo(
() => [
{ value: "all", label: "All projects" },
...projectGroups.map((project) => ({
value: project.projectKey,
label: project.displayName,
})),
],
[projectGroups],
);
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
[projectGroups],
);
const selectedProjectScopeItem = useMemo(
() =>
projectScopeItems.find((item) => item.value === (projectScopeKey ?? "all")) ??
projectScopeItems[0]!,
[projectScopeItems, projectScopeKey],
);
const [projectScopeMenuState, dispatchProjectScopeMenu] = useReducer(
reduceSidebarProjectScopeMenuState,
{ open: false, query: "" },
);
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.
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],
);
const scopedProjectGroup = useMemo(
() =>
projectScopeKey === null
Expand Down Expand Up @@ -2000,7 +2056,7 @@ export default function Sidebar() {
(event: ReactMouseEvent<HTMLButtonElement>, projectGroup: SidebarProjectSnapshot) => {
event.preventDefault();
event.stopPropagation();
setProjectScopeMenuOpen(false);
dispatchProjectScopeMenu({ type: "project-settings-opened" });
if (isMobile) {
setOpenMobile(false);
}
Expand Down Expand Up @@ -3490,8 +3546,23 @@ export default function Sidebar() {
</div>
{projectGroups.length > 0 ? (
<div className="flex items-center gap-1">
<Menu open={projectScopeMenuOpen} onOpenChange={setProjectScopeMenuOpen}>
<MenuTrigger
<Combobox
items={projectScopeItems}
filteredItems={filteredProjectScopeItems}
autoHighlight
itemToStringLabel={(item) => item.label}
isItemEqualToValue={(a, b) => a.value === b.value}
open={projectScopeMenuState.open}
onOpenChange={(open) => {
dispatchProjectScopeMenu({ type: "open-changed", open });
}}
value={selectedProjectScopeItem}
onValueChange={(item) => {
if (!item) return;
setProjectScopeKey(item.value === "all" ? null : item.value);
}}
Comment thread
cursor[bot] marked this conversation as resolved.
>
<ComboboxTrigger
render={
<SidebarMenuButton
aria-label="Filter threads by project"
Expand All @@ -3513,57 +3584,76 @@ export default function Sidebar() {
{scopedProjectGroup?.displayName ?? "All projects"}
</span>
<ChevronDownIcon className="-mr-px size-4 shrink-0" />
</MenuTrigger>
<MenuPopup align="start" className="w-(--anchor-width)">
<MenuRadioGroup
value={projectScopeKey ?? "all"}
onValueChange={(value) =>
setProjectScopeKey(value === "all" ? null : (value as string))
}
>
<MenuRadioItem
value="all"
closeOnClick
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"
>
<FolderIcon className="size-4 shrink-0" />
<span className="min-w-0 truncate text-sm">All projects</span>
</MenuRadioItem>
{projectGroups.map((project) => {
const scopeKey = project.projectKey;
</ComboboxTrigger>
<ComboboxPopup align="start" className="w-(--anchor-width)">
<div className="shrink-0 px-3 pt-2.5">
<div className="relative -translate-y-px border-b border-border/70 pb-1.5 transition-colors focus-within:border-ring">
<SearchIcon
aria-hidden="true"
className="pointer-events-none absolute top-1.5 left-0 size-4 shrink-0 text-muted-foreground/55"
/>
<ComboboxInput
aria-label="Search projects"
className="[&_input]:h-6.5 [&_input]:ps-5 [&_input]:font-sans [&_input]:leading-6.5"
inputClassName="rounded-none bg-transparent text-sm"
placeholder="Search projects..."
showTrigger={false}
size="sm"
unstyled
value={projectScopeMenuState.query}
onChange={(event) =>
dispatchProjectScopeMenu({
type: "query-changed",
query: event.target.value,
})
}
/>
</div>
</div>
<ComboboxEmpty>No matching projects.</ComboboxEmpty>
<ComboboxList>
{(item: (typeof projectScopeItems)[number]) => {
const project = projectGroupByScopeKey.get(item.value) ?? null;
return (
<MenuRadioItem
key={scopeKey}
value={scopeKey}
closeOnClick
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"
<ComboboxItem
key={item.value}
hideIndicator
value={item}
className="h-8 min-h-8 py-0 font-medium"
contentClassName="flex min-w-0 items-center gap-2"
>
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.workspaceRoot}
faviconPath={project.faviconPath}
className="size-4 shrink-0"
/>
<span className="min-w-0 truncate text-sm">{project.displayName}</span>
<Button
size="icon-xs"
variant="ghost-muted"
aria-label={`Project settings for ${project.displayName}`}
title={`Project settings for ${project.displayName}`}
className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
void handleProjectSettings(event, project);
}}
>
<SettingsIcon className="size-3.5" />
</Button>
</MenuRadioItem>
{project ? (
<ProjectFavicon
environmentId={project.environmentId}
cwd={project.workspaceRoot}
faviconPath={project.faviconPath}
className="size-4 shrink-0"
/>
) : (
<FolderIcon className="size-4 shrink-0" />
)}
<span className="min-w-0 flex-1 truncate text-sm">{item.label}</span>
{project ? (
<Button
size="icon-xs"
variant="ghost-muted"
aria-label={`Project settings for ${project.displayName}`}
title={`Project settings for ${project.displayName}`}
className="ml-auto size-6 [--control-icon-color:currentColor] text-icon-muted focus-visible:bg-accent focus-visible:text-foreground"
onPointerDown={(event) => event.stopPropagation()}
onClick={(event) => {
void handleProjectSettings(event, project);
}}
>
<SettingsIcon className="size-3.5" />
</Button>
) : null}
</ComboboxItem>
);
})}
</MenuRadioGroup>
</MenuPopup>
</Menu>
}}
</ComboboxList>
</ComboboxPopup>
</Combobox>
<Tooltip>
<TooltipTrigger
render={
Expand Down
Loading