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
@@ -0,0 +1,127 @@
|
||||
import { expect } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { gotoAppShell } from "../support/helpers/app";
|
||||
import { seedWorkspace } from "../support/helpers/seed-client";
|
||||
import { getServerId } from "../support/helpers/server-id";
|
||||
import {
|
||||
closeSidebarDisplayPreferences,
|
||||
openSidebarProjectFilter,
|
||||
pinWorkspaceFromSidebar,
|
||||
selectAllProjectsFilter,
|
||||
selectSidebarStatusGrouping,
|
||||
toggleProjectFilter,
|
||||
} from "../support/helpers/sidebar";
|
||||
|
||||
test.describe("Sidebar project filter", () => {
|
||||
test.describe.configure({ timeout: 180_000 });
|
||||
|
||||
test("pins the sidebar to one project across both grouping modes", async ({ page }) => {
|
||||
// Two temp repos means two projects, which is also what makes the `Project ›` row appear.
|
||||
const alpha = await seedWorkspace({ repoPrefix: "project-filter-alpha-", title: "Alpha work" });
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-beta-", title: "Beta work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
const betaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`);
|
||||
const filterTrigger = page.getByTestId("sidebar-display-project-filter");
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await expect(page.getByTestId("sidebar-project-filter-all")).toBeVisible();
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
// The indicator reads the filter as it is applied, so it must be on here.
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(filterTrigger).toBeVisible();
|
||||
await expect(filterTrigger.getByTestId("menu-sub-indicator")).toBeVisible();
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
// Status grouping builds its rows from the workspace entries rather than the projects
|
||||
// array, so a filter applied in only one of the two places passes every check above and
|
||||
// silently fails right here.
|
||||
await selectSidebarStatusGrouping(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 15_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
// The filter is view state and survives a cold load.
|
||||
await page.reload();
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
await expect(betaRow).toHaveCount(0);
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
await expect(betaRow).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps the display menu reachable when the pinned section swallows the filtered project", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Pinning hoists a chat out of its project, and a project whose chats are ALL hoisted is
|
||||
// dropped from the project list entirely. Filter to that project and the list body has no
|
||||
// project rows left — so the header, which carries the only route back to the filter page,
|
||||
// has to survive on the strength of the filter alone.
|
||||
const alpha = await seedWorkspace({
|
||||
repoPrefix: "project-filter-pinned-",
|
||||
title: "Pinned work",
|
||||
});
|
||||
const beta = await seedWorkspace({ repoPrefix: "project-filter-other-", title: "Other work" });
|
||||
const serverId = getServerId();
|
||||
const alphaRow = page.getByTestId(`sidebar-workspace-row-${serverId}:${alpha.workspaceId}`);
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(alphaRow).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await openSidebarProjectFilter(page);
|
||||
await toggleProjectFilter(page, alpha.projectKey);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
|
||||
await pinWorkspaceFromSidebar(page, alpha.workspaceId);
|
||||
await expect(alphaRow).toBeVisible();
|
||||
|
||||
// The way out of the filter is still on screen.
|
||||
await expect(page.getByTestId("sidebar-display-preferences-menu")).toBeVisible();
|
||||
await openSidebarProjectFilter(page);
|
||||
await selectAllProjectsFilter(page);
|
||||
await closeSidebarDisplayPreferences(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${beta.workspaceId}`),
|
||||
).toBeVisible({ timeout: 15_000 });
|
||||
} finally {
|
||||
await beta.cleanup();
|
||||
await alpha.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("hides the filter row when there is only one project", async ({ page }) => {
|
||||
const only = await seedWorkspace({ repoPrefix: "project-filter-solo-", title: "Solo work" });
|
||||
const serverId = getServerId();
|
||||
|
||||
try {
|
||||
await gotoAppShell(page);
|
||||
await expect(
|
||||
page.getByTestId(`sidebar-workspace-row-${serverId}:${only.workspaceId}`),
|
||||
).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await page.getByTestId("sidebar-display-preferences-menu").click();
|
||||
await expect(page.getByTestId("sidebar-display-preferences-content")).toBeVisible();
|
||||
await expect(page.getByTestId("sidebar-display-project-filter")).toHaveCount(0);
|
||||
} finally {
|
||||
await only.cleanup();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -266,7 +266,7 @@ test.describe("Workspace labels", () => {
|
||||
|
||||
// Clear restores the complete sidebar without leaving the filter page.
|
||||
await page.getByTestId("sidebar-label-filter-clear").click();
|
||||
await expect(page.getByTestId("sidebar-label-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-filter-empty-state")).toBeHidden();
|
||||
await expect(page.getByTestId("sidebar-label-filter-clear")).toBeHidden();
|
||||
await expect(labelledRow).toBeVisible();
|
||||
await expect(unlabelledRow).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user