diff --git a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx
index a91ac6c67b..ac74ee6792 100644
--- a/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx
+++ b/apps/web/components/task/sidebar-filter/sidebar-view-chips.tsx
@@ -1,40 +1,147 @@
"use client";
-import { cn } from "@/lib/utils";
+import { useCallback, useRef, type PointerEvent } from "react";
+import {
+ DndContext,
+ PointerSensor,
+ closestCenter,
+ type DragEndEvent,
+ useSensor,
+ useSensors,
+} from "@dnd-kit/core";
+import { SortableContext, horizontalListSortingStrategy, useSortable } from "@dnd-kit/sortable";
+import { CSS } from "@dnd-kit/utilities";
import { useAppStore } from "@/components/state-provider";
+import type { SidebarView } from "@/lib/state/slices/ui/sidebar-view-types";
+import { cn } from "@/lib/utils";
+
+const DRAG_ACTIVATION_DISTANCE = 8;
export function SidebarViewChips() {
const views = useAppStore((s) => s.sidebarViews.views);
const activeViewId = useAppStore((s) => s.sidebarViews.activeViewId);
const setActive = useAppStore((s) => s.setSidebarActiveView);
+ const reorderViews = useAppStore((s) => s.reorderSidebarViews);
+ const sensors = useSensors(
+ useSensor(PointerSensor, { activationConstraint: { distance: DRAG_ACTIVATION_DISTANCE } }),
+ );
+
+ const handleDragEnd = useCallback(
+ (event: DragEndEvent) => {
+ const { active, over } = event;
+ if (!over || active.id === over.id) return;
+ reorderViews(String(active.id), String(over.id));
+ },
+ [reorderViews],
+ );
return (
-
- {views.map((view) => {
- const active = view.id === activeViewId;
- return (
-
- );
- })}
-
+
+ view.id)}
+ strategy={horizontalListSortingStrategy}
+ >
+
+ {views.map((view) => (
+ setActive(view.id)}
+ />
+ ))}
+
+
+
+ );
+}
+
+function SidebarViewChip({
+ view,
+ active,
+ onSelect,
+}: {
+ view: SidebarView;
+ active: boolean;
+ onSelect: () => void;
+}) {
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: view.id,
+ });
+ const sortableAttributes = {
+ ...attributes,
+ role: undefined,
+ "aria-roledescription": undefined,
+ };
+ const pointerStartRef = useRef<{ x: number; y: number } | null>(null);
+ const pointerMovedRef = useRef(false);
+ const skipNextClickRef = useRef(false);
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : undefined,
+ };
+ const resetPointerState = useCallback(() => {
+ pointerStartRef.current = null;
+ pointerMovedRef.current = false;
+ }, []);
+ const handlePointerDownCapture = useCallback((event: PointerEvent) => {
+ pointerStartRef.current = { x: event.clientX, y: event.clientY };
+ pointerMovedRef.current = false;
+ }, []);
+ const handlePointerMoveCapture = useCallback((event: PointerEvent) => {
+ const start = pointerStartRef.current;
+ if (!start) return;
+ const distance = Math.hypot(event.clientX - start.x, event.clientY - start.y);
+ if (distance >= DRAG_ACTIVATION_DISTANCE) pointerMovedRef.current = true;
+ }, []);
+ const handlePointerUpCapture = useCallback(() => {
+ const shouldSelect = pointerStartRef.current && !pointerMovedRef.current;
+ resetPointerState();
+ if (!shouldSelect) return;
+ skipNextClickRef.current = true;
+ window.setTimeout(() => {
+ skipNextClickRef.current = false;
+ }, 0);
+ onSelect();
+ }, [onSelect, resetPointerState]);
+ const handleClick = useCallback(() => {
+ if (skipNextClickRef.current) {
+ skipNextClickRef.current = false;
+ return;
+ }
+ onSelect();
+ }, [onSelect]);
+
+ return (
+
);
}
diff --git a/apps/web/e2e/pages/sidebar-filter-popover.ts b/apps/web/e2e/pages/sidebar-filter-popover.ts
index c81cb9ae43..6aac42a374 100644
--- a/apps/web/e2e/pages/sidebar-filter-popover.ts
+++ b/apps/web/e2e/pages/sidebar-filter-popover.ts
@@ -30,7 +30,7 @@ export class SidebarFilterPopoverPage {
}
async selectViewByName(name: string): Promise {
- await this.chipRow.locator(`[data-testid='sidebar-view-chip']`, { hasText: name }).click();
+ await this.chipByName(name).click();
}
async expectActiveViewChip(name: string): Promise {
@@ -40,6 +40,49 @@ export class SidebarFilterPopoverPage {
await expect(chip).toContainText(name);
}
+ chipByName(name: string): Locator {
+ return this.chipRow.getByTestId("sidebar-view-chip").filter({ hasText: name }).first();
+ }
+
+ async expectChipOrder(names: string[]): Promise {
+ await expect
+ .poll(async () => {
+ const texts = await this.chipRow.getByTestId("sidebar-view-chip").allTextContents();
+ return texts.map((text) => text.trim());
+ })
+ .toEqual(names);
+ }
+
+ async dragViewBefore(sourceName: string, targetName: string): Promise {
+ const source = this.chipByName(sourceName);
+ const target = this.chipByName(targetName);
+ await source.scrollIntoViewIfNeeded();
+ await target.scrollIntoViewIfNeeded();
+ await expect(source).toBeVisible();
+
+ const sourceBox = await source.boundingBox();
+ const targetBox = await target.boundingBox();
+ if (!sourceBox || !targetBox) throw new Error("Missing sidebar view chip drag geometry");
+ const targetLeftHalfX = targetBox.x + targetBox.width * 0.25;
+
+ await this.page.mouse.move(
+ sourceBox.x + sourceBox.width / 2,
+ sourceBox.y + sourceBox.height / 2,
+ );
+ await this.page.mouse.down();
+ // Move far enough to exceed the 8px PointerSensor activation threshold,
+ // then slide toward the target.
+ await this.page.mouse.move(
+ sourceBox.x + sourceBox.width / 2 - 16,
+ sourceBox.y + sourceBox.height / 2,
+ { steps: 4 },
+ );
+ await this.page.mouse.move(targetLeftHalfX, targetBox.y + targetBox.height / 2, {
+ steps: 20,
+ });
+ await this.page.mouse.up();
+ }
+
async addFilterRow(): Promise {
await this.open();
await this.popover.getByTestId("filter-add-button").click();
diff --git a/apps/web/e2e/tests/task/sidebar-filter.spec.ts b/apps/web/e2e/tests/task/sidebar-filter.spec.ts
index 3a7ccdc061..5b40b68db6 100644
--- a/apps/web/e2e/tests/task/sidebar-filter.spec.ts
+++ b/apps/web/e2e/tests/task/sidebar-filter.spec.ts
@@ -42,6 +42,35 @@ async function openWithSeed(
return { session, filters };
}
+async function saveTitleView(
+ filters: SidebarFilterPopoverPage,
+ name: string,
+ titleFilter: string,
+): Promise {
+ await filters.close();
+ await filters.selectViewByName("All tasks");
+ await filters.expectActiveViewChip("All tasks");
+ await filters.addFilterRow();
+ await filters.setClauseDimension(0, "Title");
+ await filters.setClauseTextValue(0, titleFilter);
+ await filters.saveAs(name);
+ await filters.expectActiveViewChip(name);
+ await filters.close();
+}
+
+async function expectStoredSidebarViewOrder(
+ apiClient: import("../../helpers/api-client").ApiClient,
+ names: string[],
+): Promise {
+ await expect
+ .poll(async () => {
+ const response = await apiClient.getUserSettings();
+ const views = (response.settings.sidebar_views ?? []) as Array<{ name: string }>;
+ return views.map((view) => view.name);
+ })
+ .toEqual(names);
+}
+
test.describe("Sidebar filter bar — popover basics", () => {
test("gear opens popover; ESC closes it", async ({ testPage, apiClient, seedData }) => {
const { filters } = await openWithSeed(testPage, apiClient, seedData, ["Basics Task"]);
@@ -82,6 +111,69 @@ test.describe("Sidebar filter bar — popover basics", () => {
});
});
+test.describe("Sidebar filter — view ordering", () => {
+ test("dragged view order persists to settings and survives reload", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const { filters } = await openWithSeed(testPage, apiClient, seedData, [
+ "Order alpha task",
+ "Order beta task",
+ "Order gamma task",
+ ]);
+ await saveTitleView(filters, "Alpha View", "alpha");
+ await saveTitleView(filters, "Beta View", "beta");
+ await saveTitleView(filters, "Gamma View", "gamma");
+ await filters.expectChipOrder(["All tasks", "Alpha View", "Beta View", "Gamma View"]);
+
+ await filters.dragViewBefore("Gamma View", "All tasks");
+
+ const reorderedNames = ["Gamma View", "All tasks", "Alpha View", "Beta View"];
+ await filters.expectChipOrder(reorderedNames);
+ await expectStoredSidebarViewOrder(apiClient, reorderedNames);
+
+ await testPage.reload();
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ const filters2 = new SidebarFilterPopoverPage(testPage);
+ await filters2.expectChipOrder(reorderedNames);
+ await filters2.expectActiveViewChip("Gamma View");
+ });
+
+ test("reordered views still select, delete, and append normally", async ({
+ testPage,
+ apiClient,
+ seedData,
+ }) => {
+ const { session, filters } = await openWithSeed(testPage, apiClient, seedData, [
+ "Select alpha task",
+ "Select beta task",
+ "Select gamma task",
+ ]);
+ await saveTitleView(filters, "Alpha View", "alpha");
+ await saveTitleView(filters, "Beta View", "beta");
+ await saveTitleView(filters, "Gamma View", "gamma");
+ await filters.dragViewBefore("Gamma View", "All tasks");
+ await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Beta View"]);
+
+ await filters.selectViewByName("Alpha View");
+ await filters.expectActiveViewChip("Alpha View");
+ await expect(session.sidebar.getByText("Select alpha task")).toBeVisible();
+ await expect(session.sidebar.getByText("Select beta task")).toHaveCount(0);
+
+ await filters.selectViewByName("Beta View");
+ await filters.open();
+ await filters.deleteActiveView();
+ await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View"]);
+ await filters.expectActiveViewChip("Gamma View");
+
+ await saveTitleView(filters, "Delta View", "delta");
+ await filters.expectChipOrder(["Gamma View", "All tasks", "Alpha View", "Delta View"]);
+ await filters.expectActiveViewChip("Delta View");
+ });
+});
+
test.describe("Sidebar filter — filtering", () => {
test("adding a title filter narrows the list live", async ({ testPage, apiClient, seedData }) => {
const { session, filters } = await openWithSeed(testPage, apiClient, seedData, [
diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts
index bceb5cf9a2..aedf9c1e5f 100644
--- a/apps/web/lib/state/slices/ui/types.ts
+++ b/apps/web/lib/state/slices/ui/types.ts
@@ -165,6 +165,7 @@ export type UISliceActions = {
deleteSidebarView: (viewId: string) => void;
renameSidebarView: (viewId: string, name: string) => void;
duplicateSidebarView: (viewId: string, name: string) => void;
+ reorderSidebarViews: (activeViewId: string, overViewId: string) => void;
toggleSidebarGroupCollapsed: (viewId: string, groupKey: string) => void;
toggleSubtaskCollapsed: (parentTaskId: string) => void;
clearSidebarSyncError: () => void;
diff --git a/apps/web/lib/state/slices/ui/ui-slice.test.ts b/apps/web/lib/state/slices/ui/ui-slice.test.ts
index 3c59821537..975bb2eec8 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.test.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts
@@ -1,9 +1,15 @@
-import { beforeEach, describe, expect, it } from "vitest";
+import { beforeEach, describe, expect, it, vi } from "vitest";
import { create } from "zustand";
import { immer } from "zustand/middleware/immer";
+import { updateUserSettings } from "@/lib/api/domains/settings-api";
import { createUISlice } from "./ui-slice";
+import type { SidebarViewDraft } from "./sidebar-view-types";
import type { UISlice } from "./types";
+vi.mock("@/lib/api/domains/settings-api", () => ({
+ updateUserSettings: vi.fn(() => Promise.resolve({ settings: {} })),
+}));
+
function makeStore() {
return create()(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,6 +20,18 @@ function makeStore() {
const KEY = "kandev.sidebar.collapsedSubtasks";
const TASK_A = "task-a";
const TASK_B = "task-b";
+const SIDEBAR_VIEWS_KEY = "kandev.sidebar.views";
+
+function makeSidebarView(id: string, name: string) {
+ return {
+ id,
+ name,
+ filters: [],
+ sort: { key: "state" as const, direction: "asc" as const },
+ group: "none" as const,
+ collapsedGroups: [],
+ };
+}
describe("toggleSubtaskCollapsed", () => {
beforeEach(() => {
@@ -54,3 +72,99 @@ describe("toggleSubtaskCollapsed", () => {
expect(store.getState().collapsedSubtaskParents).toEqual([TASK_B]);
});
});
+
+describe("reorderSidebarViews", () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.mocked(updateUserSettings).mockClear();
+ });
+
+ it("reorders by id, persists the order, and syncs the backend payload", () => {
+ const store = makeStore();
+ store.setState((state) => ({
+ ...state,
+ sidebarViews: {
+ ...state.sidebarViews,
+ views: [
+ makeSidebarView("all", "All"),
+ makeSidebarView("one", "One"),
+ makeSidebarView("two", "Two"),
+ ],
+ activeViewId: "two",
+ draft: null,
+ },
+ }));
+
+ store.getState().reorderSidebarViews("two", "one");
+
+ expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["all", "two", "one"]);
+ expect(JSON.parse(window.localStorage.getItem(SIDEBAR_VIEWS_KEY) ?? "[]")).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ id: "all" }),
+ expect.objectContaining({ id: "two" }),
+ expect.objectContaining({ id: "one" }),
+ ]),
+ );
+ expect(
+ JSON.parse(window.localStorage.getItem(SIDEBAR_VIEWS_KEY) ?? "[]").map(
+ (v: { id: string }) => v.id,
+ ),
+ ).toEqual(["all", "two", "one"]);
+ expect(updateUserSettings).toHaveBeenCalledWith({
+ sidebar_views: [
+ expect.objectContaining({ id: "all" }),
+ expect.objectContaining({ id: "two" }),
+ expect.objectContaining({ id: "one" }),
+ ],
+ });
+ });
+
+ it("keeps the active view and draft while reordering", () => {
+ const draft: SidebarViewDraft = {
+ baseViewId: "one",
+ filters: [{ id: "c1", dimension: "titleMatch", op: "matches", value: "bug" }],
+ sort: { key: "title", direction: "asc" },
+ group: "workflow",
+ };
+ const store = makeStore();
+ store.setState((state) => ({
+ ...state,
+ sidebarViews: {
+ ...state.sidebarViews,
+ views: [
+ makeSidebarView("all", "All"),
+ makeSidebarView("one", "One"),
+ makeSidebarView("two", "Two"),
+ ],
+ activeViewId: "one",
+ draft,
+ },
+ }));
+
+ store.getState().reorderSidebarViews("two", "all");
+
+ expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["two", "all", "one"]);
+ expect(store.getState().sidebarViews.activeViewId).toBe("one");
+ expect(store.getState().sidebarViews.draft).toEqual(draft);
+ });
+
+ it("no-ops when ids are equal or missing", () => {
+ const store = makeStore();
+ const views = [
+ makeSidebarView("all", "All"),
+ makeSidebarView("one", "One"),
+ makeSidebarView("two", "Two"),
+ ];
+ store.setState((state) => ({
+ ...state,
+ sidebarViews: { ...state.sidebarViews, views, activeViewId: "all", draft: null },
+ }));
+
+ store.getState().reorderSidebarViews("one", "one");
+ store.getState().reorderSidebarViews("missing", "one");
+ store.getState().reorderSidebarViews("one", "missing");
+
+ expect(store.getState().sidebarViews.views.map((v) => v.id)).toEqual(["all", "one", "two"]);
+ expect(updateUserSettings).not.toHaveBeenCalled();
+ });
+});
diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts
index daa5a58419..10005d25e8 100644
--- a/apps/web/lib/state/slices/ui/ui-slice.ts
+++ b/apps/web/lib/state/slices/ui/ui-slice.ts
@@ -71,6 +71,21 @@ function makeId(prefix: string): string {
return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
+function reorderViewsById(
+ views: SidebarView[],
+ activeViewId: string,
+ overViewId: string,
+): SidebarView[] | null {
+ if (activeViewId === overViewId) return null;
+ const oldIndex = views.findIndex((v) => v.id === activeViewId);
+ const newIndex = views.findIndex((v) => v.id === overViewId);
+ if (oldIndex === -1 || newIndex === -1) return null;
+ const next = [...views];
+ const [moved] = next.splice(oldIndex, 1);
+ next.splice(newIndex, 0, moved);
+ return next;
+}
+
export const defaultUIState: UISliceState = {
previewPanel: {
openBySessionId: {},
@@ -372,6 +387,12 @@ function buildSidebarBackendActions(set: ImmerSet, get: () => UISlice) {
if (!next || next === view.name) return false;
view.name = next;
}),
+ reorderSidebarViews: (activeViewId: string, overViewId: string) =>
+ mv((s) => {
+ const reordered = reorderViewsById(s.views, activeViewId, overViewId);
+ if (!reordered) return false;
+ s.views = reordered;
+ }),
};
}
diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts
index ac6b33db45..58e0e4ac83 100644
--- a/apps/web/lib/state/store.ts
+++ b/apps/web/lib/state/store.ts
@@ -475,6 +475,7 @@ export type AppState = {
deleteSidebarView: UIA["deleteSidebarView"];
renameSidebarView: UIA["renameSidebarView"];
duplicateSidebarView: UIA["duplicateSidebarView"];
+ reorderSidebarViews: UIA["reorderSidebarViews"];
toggleSidebarGroupCollapsed: UIA["toggleSidebarGroupCollapsed"];
toggleSubtaskCollapsed: UIA["toggleSubtaskCollapsed"];
clearSidebarSyncError: UIA["clearSidebarSyncError"];