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:
Christoph Leiter
2026-08-20 23:51:35 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 33224ace47
commit c3d37eba4b
23 changed files with 747 additions and 78 deletions
@@ -42,6 +42,7 @@ describe("sidebar view store", () => {
useSidebarViewStore.setState({
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: { labels: [] },
});
});
@@ -93,6 +94,7 @@ describe("sidebar view store", () => {
).toEqual({
groupMode: "status",
hostFilters: [],
projectFilters: [],
labelFilter: { labels: [] },
});
});
@@ -106,6 +108,7 @@ describe("sidebar view store", () => {
).toEqual({
groupMode: "status",
hostFilters: ["host-a"],
projectFilters: [],
labelFilter: { labels: [] },
});
});
@@ -119,6 +122,7 @@ describe("sidebar view store", () => {
).toEqual({
groupMode: "status",
hostFilters: ["host-a", "host-b"],
projectFilters: [],
labelFilter: { labels: [] },
});
});
@@ -182,6 +186,66 @@ describe("sidebar view store", () => {
).toEqual({ labels: ["urgent", "blocked"] });
});
it("toggles multiple projects into and out of the filter", () => {
const store = useSidebarViewStore.getState();
store.toggleProjectFilter("project-a");
store.toggleProjectFilter("project-b");
expect(useSidebarViewStore.getState().projectFilters).toEqual(["project-a", "project-b"]);
store.toggleProjectFilter("project-a");
expect(useSidebarViewStore.getState().projectFilters).toEqual(["project-b"]);
store.clearProjectFilters();
expect(useSidebarViewStore.getState().projectFilters).toEqual([]);
});
it("keeps the other facets when the project filter is cleared", () => {
useSidebarViewStore.setState({
groupMode: "status",
hostFilters: ["host-a"],
projectFilters: ["project-a"],
labelFilter: { labels: ["urgent"] },
});
useSidebarViewStore.getState().clearProjectFilters();
expect(useSidebarViewStore.getState()).toMatchObject({
groupMode: "status",
hostFilters: ["host-a"],
projectFilters: [],
labelFilter: { labels: ["urgent"] },
});
});
// The persisted schema is a `z.strictObject` behind `createValidatedPersistStorage`, so a key
// the schema does not list fails the parse and takes every other sidebar setting down with it.
it("carries a persisted project filter through the version migration", () => {
expect(
migrateSidebarViewState({
groupMode: "project",
hostFilters: ["host-a"],
projectFilters: ["project-a", "project-b"],
}),
).toEqual({
groupMode: "project",
hostFilters: ["host-a"],
projectFilters: ["project-a", "project-b"],
labelFilter: { labels: [] },
});
});
it("never keeps project filters from state the schema rejects", () => {
expect(migrateSidebarViewState({ projectFilters: "project-a" })).toEqual({
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: { labels: [] },
});
});
it("falls back to the legacy storage key when the new key is empty", async () => {
const storage = createMemoryStorage({
"sidebar-view": null,
+46 -8
View File
@@ -9,7 +9,7 @@ export type SidebarGroupMode = "project" | "status";
const SIDEBAR_VIEW_STORAGE_KEY = "sidebar-view";
const LEGACY_SIDEBAR_GROUP_MODE_STORAGE_KEY = "sidebar-group-mode";
const SIDEBAR_VIEW_STORE_VERSION = 5;
const SIDEBAR_VIEW_STORE_VERSION = 6;
/**
* The key standing for "this workspace carries no labels at all".
@@ -34,14 +34,38 @@ export function hasActiveSidebarLabelFilter(filter: SidebarLabelFilter): boolean
return filter.labels.length > 0;
}
/**
* Include/exclude toggle over an allowlist, shared by the host and project filters.
*
* Both filters answer the same question — "is this one of the things I pinned the sidebar to" —
* so they share the operation. The label filter does not: its keys go through
* `workspaceLabelKey` first, which is a different identity.
*/
function toggleFilterEntry(list: readonly string[], key: string): string[] {
return list.includes(key) ? list.filter((entry) => entry !== key) : [...list, key];
}
interface SidebarViewStoreState {
groupMode: SidebarGroupMode;
// Empty means "all hosts". A non-empty list pins the sidebar to those hosts.
hostFilters: string[];
/**
* Empty means "all projects". A non-empty list is an allowlist over
* `SidebarProjectEntry.viewKey` — the same key the project sections are built from.
*
* There is deliberately no `reconcileProjectFilters` counterpart to `reconcileHostFilters`.
* The project list is narrowed by the host filter and is empty before any host connects, so
* reconciling against it would silently destroy the filter on every cold start and every
* host-filter change. Stale keys are resolved away at read time instead — see
* `resolveActiveProjectFilters`.
*/
projectFilters: string[];
labelFilter: SidebarLabelFilter;
setGroupMode: (mode: SidebarGroupMode) => void;
toggleHostFilter: (serverId: string) => void;
clearHostFilters: () => void;
toggleProjectFilter: (viewKey: string) => void;
clearProjectFilters: () => void;
toggleLabelFilter: (name: string) => void;
clearLabelFilter: () => void;
reconcileLabelFilter: (labels: readonly string[]) => void;
@@ -51,6 +75,7 @@ interface SidebarViewStoreState {
interface SidebarViewPersistedState {
groupMode: SidebarGroupMode;
hostFilters: string[];
projectFilters: string[];
labelFilter: SidebarLabelFilter;
}
@@ -62,6 +87,7 @@ const SidebarViewPersistedStateSchema = z.strictObject({
groupMode: PersistedSidebarGroupModeSchema.optional(),
hostFilters: z.array(z.string()).optional(),
hostFilter: z.string().nullable().optional(),
projectFilters: z.array(z.string()).optional(),
groupModeByServerId: z.record(z.string(), PersistedSidebarGroupModeSchema).optional(),
labelFilter: SidebarLabelFilterSchema.optional(),
});
@@ -95,18 +121,29 @@ function readHostFilters(persistedState: SidebarViewStorageState): string[] {
export function migrateSidebarViewState(persistedState: unknown): SidebarViewPersistedState {
const result = SidebarViewPersistedStateSchema.safeParse(persistedState);
if (!result.success) {
return { groupMode: "project", hostFilters: [], labelFilter: emptyLabelFilter() };
return {
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: emptyLabelFilter(),
};
}
const state = result.data;
const legacyGroupMode = readLegacyGroupMode(state);
if (legacyGroupMode) {
return { groupMode: legacyGroupMode, hostFilters: [], labelFilter: emptyLabelFilter() };
return {
groupMode: legacyGroupMode,
hostFilters: [],
projectFilters: [],
labelFilter: emptyLabelFilter(),
};
}
return {
groupMode: state.groupMode === "status" ? "status" : "project",
hostFilters: readHostFilters(state),
projectFilters: state.projectFilters ?? [],
labelFilter: state.labelFilter
? normalizeSidebarLabelFilter(state.labelFilter)
: emptyLabelFilter(),
@@ -143,15 +180,15 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
(set) => ({
groupMode: "project",
hostFilters: [],
projectFilters: [],
labelFilter: emptyLabelFilter(),
setGroupMode: (mode) => set({ groupMode: mode }),
toggleHostFilter: (serverId) =>
set((state) => ({
hostFilters: state.hostFilters.includes(serverId)
? state.hostFilters.filter((id) => id !== serverId)
: [...state.hostFilters, serverId],
})),
set((state) => ({ hostFilters: toggleFilterEntry(state.hostFilters, serverId) })),
clearHostFilters: () => set({ hostFilters: [] }),
toggleProjectFilter: (viewKey) =>
set((state) => ({ projectFilters: toggleFilterEntry(state.projectFilters, viewKey) })),
clearProjectFilters: () => set({ projectFilters: [] }),
toggleLabelFilter: (name) =>
set((state) => {
const key = workspaceLabelKey(name);
@@ -193,6 +230,7 @@ export const useSidebarViewStore = create<SidebarViewStoreState>()(
partialize: (state) => ({
groupMode: state.groupMode,
hostFilters: state.hostFilters,
projectFilters: state.projectFilters,
labelFilter: state.labelFilter,
}),
migrate: migrateSidebarViewState,