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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user