feat(app): filter the sidebar by project (#3563)
The sidebar can be pinned to a set of hosts or to a set of labels, but not to a set of projects. If you keep many projects on one host, the project is the filter you want. Add a Project page to the sidebar display menu, next to Host. It multi-selects projects, and an empty selection means every project. Apply the filter at two points. Project grouping reads the projects array, but Status grouping builds its rows from the workspace entries. A filter applied to only one of the two leaves the other mode unfiltered. Apply nothing when a stored selection names no visible project. The project list is narrowed by the host filter, and it is empty before any host connects, so dropping keys that do not match would destroy the selection on every cold start. Count the project filter in the sidebar header gate. Pinning every chat in a project drops that project from the unpinned list. Filtering to that project then removed the header, and the header carries the display menu, which is the only way back to the Project page. Generalise the label filter empty state to all sidebar filters, and move its strings out of the labels namespace. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
33224ace47
commit
c3d37eba4b
@@ -118,7 +118,7 @@ import {
|
||||
} from "@/components/sidebar/sidebar-workspace-row-content";
|
||||
import { useOpenKebabMenuVisibility } from "@/components/sidebar/use-open-kebab-menu-visibility";
|
||||
import {
|
||||
SidebarLabelFilterEmptyState,
|
||||
SidebarFilterEmptyState,
|
||||
SidebarProjectEmptyState,
|
||||
} from "@/components/sidebar/empty-states";
|
||||
import { selectWorkspaceServiceSummary } from "@/components/sidebar/workspace-meta-row";
|
||||
@@ -245,7 +245,9 @@ interface SidebarWorkspaceListProps {
|
||||
projectIconTargets: SidebarProjectIconTarget[];
|
||||
pinnedGroups: PinnedSidebarGroups;
|
||||
projects: SidebarProjectEntry[];
|
||||
hasProjectsBeforeLabelFilter: boolean;
|
||||
hasProjectsBeforeFilter: boolean;
|
||||
/** Whether a project filter is actually being applied — the resolved list, not the stored one. */
|
||||
hasActiveProjectFilter: boolean;
|
||||
workspaceEntriesByKey: ReadonlyMap<string, SidebarWorkspaceEntry>;
|
||||
collapsedProjectKeys: ReadonlySet<string>;
|
||||
onToggleProjectCollapsed: (projectViewKey: string) => void;
|
||||
@@ -1935,7 +1937,8 @@ export function SidebarWorkspaceList({
|
||||
projectIconTargets,
|
||||
pinnedGroups,
|
||||
projects,
|
||||
hasProjectsBeforeLabelFilter,
|
||||
hasProjectsBeforeFilter,
|
||||
hasActiveProjectFilter,
|
||||
workspaceEntriesByKey,
|
||||
collapsedProjectKeys,
|
||||
onToggleProjectCollapsed,
|
||||
@@ -1999,8 +2002,12 @@ export function SidebarWorkspaceList({
|
||||
// A filter that matches nothing swaps the list's body and nothing above it. It used to replace
|
||||
// this whole subtree, which unmounted the header — and the header is where the display menu's
|
||||
// trigger lives, so filtering the last row away closed the menu you were filtering from.
|
||||
const labelFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeLabelFilter && projects.length === 0;
|
||||
//
|
||||
// Only the label filter can get here. The project filter resolves against the projects it can
|
||||
// see and falls back to "all projects" when nothing matches, so it either keeps at least one
|
||||
// project or is not applied at all — it can narrow this list but never empty it.
|
||||
const sidebarFilterEmpty =
|
||||
hasActiveLabelFilter && hasProjectsBeforeFilter && projects.length === 0;
|
||||
|
||||
// Project mode is the one that keeps its project headers; every other grouping mode is a flat
|
||||
// list of grouped rows, so a new mode lands in the grouped branch rather than silently in this
|
||||
@@ -2019,7 +2026,7 @@ export function SidebarWorkspaceList({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={handlePinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2036,7 +2043,8 @@ export function SidebarWorkspaceList({
|
||||
onAddProject={onAddProject}
|
||||
listFooterComponent={listFooterComponent}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
hasActiveProjectFilter={hasActiveProjectFilter}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
pathname={pathname}
|
||||
@@ -2069,7 +2077,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin,
|
||||
onPinnedWorkspaceReorder,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
sidebarFilterEmpty,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
}: {
|
||||
@@ -2084,7 +2092,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin: ToggleSidebarWorkspacePin;
|
||||
onPinnedWorkspaceReorder: (workspaces: SidebarWorkspacePlacement[]) => void;
|
||||
listHeaderComponent?: ReactElement | null;
|
||||
labelFilterEmpty: boolean;
|
||||
sidebarFilterEmpty: boolean;
|
||||
parentGestureRef?: MutableRefObject<GestureType | undefined>;
|
||||
dragGestureHostPresented?: boolean;
|
||||
}) {
|
||||
@@ -2111,7 +2119,7 @@ function SidebarGroupedModeList({
|
||||
onToggleWorkspacePin={onToggleWorkspacePin}
|
||||
onPinnedWorkspaceReorder={onPinnedWorkspaceReorder}
|
||||
listHeaderComponent={listHeaderComponent}
|
||||
labelFilterEmpty={labelFilterEmpty}
|
||||
sidebarFilterEmpty={sidebarFilterEmpty}
|
||||
parentGestureRef={parentGestureRef}
|
||||
dragGestureHostPresented={dragGestureHostPresented}
|
||||
/>
|
||||
@@ -2130,7 +2138,8 @@ function ProjectModeList({
|
||||
onAddProject,
|
||||
listFooterComponent,
|
||||
listHeaderComponent,
|
||||
labelFilterEmpty,
|
||||
sidebarFilterEmpty,
|
||||
hasActiveProjectFilter,
|
||||
parentGestureRef,
|
||||
dragGestureHostPresented,
|
||||
pathname,
|
||||
@@ -2144,12 +2153,12 @@ function ProjectModeList({
|
||||
| "workspaceGroups"
|
||||
| "projectIconTargets"
|
||||
| "groupMode"
|
||||
| "hasProjectsBeforeLabelFilter"
|
||||
| "hasProjectsBeforeFilter"
|
||||
| "isRefreshing"
|
||||
| "onRefresh"
|
||||
> & {
|
||||
/** Swaps the list body for the label filter's empty state. Never the header above it. */
|
||||
labelFilterEmpty: boolean;
|
||||
sidebarFilterEmpty: boolean;
|
||||
projectIconByProjectViewKey: ReadonlyMap<string, string | null>;
|
||||
pathname: string;
|
||||
hostBadgeByServerId: ReadonlyMap<string, HostBadgeModel>;
|
||||
@@ -2487,11 +2496,17 @@ function ProjectModeList({
|
||||
) : null}
|
||||
{/* The header carries the display menu, which is the only way back out of a filter, so it
|
||||
stays for as long as a filter is what emptied the list. It is absent only when the
|
||||
sidebar is genuinely empty, where a section heading would sit over nothing. */}
|
||||
{unpinnedProjects.length > 0 || hasActiveHostFilter || labelFilterEmpty
|
||||
sidebar is genuinely empty, where a section heading would sit over nothing.
|
||||
Every filter that can empty this branch needs a term here: a project filter pinned to a
|
||||
project whose chats are all pinned leaves `unpinnedProjects` empty, and without its term
|
||||
the header would go with it, taking the only route back to the filter page. */}
|
||||
{unpinnedProjects.length > 0 ||
|
||||
hasActiveHostFilter ||
|
||||
hasActiveProjectFilter ||
|
||||
sidebarFilterEmpty
|
||||
? listHeaderComponent
|
||||
: null}
|
||||
{labelFilterEmpty ? <SidebarLabelFilterEmptyState /> : projectBody}
|
||||
{sidebarFilterEmpty ? <SidebarFilterEmptyState /> : projectBody}
|
||||
{listFooterComponent}
|
||||
</>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user