feat(web): drag-reorder subtasks within a parent task (#962)

* feat(web): drag-reorder subtasks within a parent task

Adds per-parent subtask ordering in the sidebar. Drag a subtask under its
parent to reorder; the chosen order is independent of the global sort and
persists per browser via localStorage.

- New `sidebarTaskPrefs.subtaskOrderByParentId` slice field and matching
  `setSubtaskOrder(parentId, ids)` action, persisted to localStorage under
  `kandev.sidebar.subtaskOrderByParentId`.
- `applyView` applies the per-parent override after `separateSubtasks`, so
  it never touches root order or the active sort spec.
- TaskSwitcher exposes `onReorderSubtasks(parentId, ids)`; each parent's
  subtask list lives in its own nested DndContext / SortableContext using
  the existing dnd-kit setup.
- Task-deletion cleanup strips the deleted id as both parent key and
  subtask entry from `subtaskOrderByParentId` in store + localStorage.

* refactor(web): address PR review feedback on subtask reordering

- Extract `pruneSubtaskOrder(map, taskId)` helper in `local-storage.ts`;
  use it in both `cleanupTaskStorage` and `removeTaskFromSidebarPrefs`
  to keep the two cleanup paths in lockstep.
- Move `SortableSubtaskRow`, `SortableSubtaskList`, and `useSubtaskDnd`
  into a new sibling file `task-switcher-subtask-dnd.tsx` so the
  switcher stays under the 600-line soft limit.
- `SortableSubtaskList` now short-circuits to a plain row map when
  `onReorderSubtasks` is undefined — read-only callers no longer get a
  misleading grab cursor or unused DnD scaffolding.

* fix(web): add stable keys to non-DnD subtask render path

When `onReorderSubtasks` is undefined, the early-return branch in
`SortableSubtaskList` mapped subtasks without keys, falling back to
React's index-based reconciliation. Wrap each child in a keyed
`Fragment` so reorders or size changes reconcile correctly.

* refactor(web): single-source DRAG_ACTIVATION_DISTANCE

Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-05-21 01:31:42 +01:00
committed by GitHub
parent 569a0d77bd
commit 35242b2982
13 changed files with 470 additions and 33 deletions
@@ -42,11 +42,22 @@ function MobileTaskList({
isLoading?: boolean;
}) {
const view = useEffectiveSidebarView();
const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } =
useSidebarTaskPrefs();
const {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
togglePinnedTask,
handleReorderGroup,
handleReorderSubtasks,
} = useSidebarTaskPrefs();
const grouped = useMemo(
() => applyView(tasks, view, { pinnedTaskIds, orderedTaskIds }),
[tasks, view, pinnedTaskIds, orderedTaskIds],
() =>
applyView(tasks, view, {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
}),
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
);
return (
<TaskSwitcher
@@ -58,6 +69,7 @@ function MobileTaskList({
onDeleteTask={onDeleteTask}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
onReorderSubtasks={handleReorderSubtasks}
pinnedTaskIds={pinnedTaskIds}
deletingTaskId={deletingTaskId}
isLoading={isLoading}
@@ -11,7 +11,7 @@ import type {
import type { TaskPR } from "@/lib/types/github";
import type { KanbanState } from "@/lib/state/slices";
import type { GitStatusEntry } from "@/lib/state/slices/session-runtime/types";
import { TaskSwitcher } from "./task-switcher";
import { TaskSwitcher, type TaskSwitcherItem } from "./task-switcher";
import { applyView } from "@/lib/sidebar/apply-view";
import { SidebarFilterBar } from "./sidebar-filter/sidebar-filter-bar";
import { MOCK_ITEMS, MOCK_SIDEBAR } from "./sidebar-mock-data";
@@ -575,6 +575,22 @@ function useBulkGitStatusSubscription(primarySessionIds: string[]) {
}, [primarySessionIds, connectionStatus, activeSessionId]);
}
function useGroupedSidebarView(displayTasks: TaskSwitcherItem[]) {
const prefs = useSidebarTaskPrefs();
const effectiveView = useEffectiveSidebarView();
const { pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId } = prefs;
const grouped = useMemo(
() =>
applyView(displayTasks, effectiveView, {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
}),
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
);
return { grouped, effectiveView, prefs };
}
export const TaskSessionSidebar = memo(function TaskSessionSidebar({
workspaceId,
}: TaskSessionSidebarProps) {
@@ -618,13 +634,8 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
const toggleSidebarGroupCollapsed = useAppStore((state) => state.toggleSidebarGroupCollapsed);
const collapsedSubtaskParents = useAppStore((state) => state.collapsedSubtaskParents);
const toggleSubtaskCollapsed = useAppStore((state) => state.toggleSubtaskCollapsed);
const { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup } =
useSidebarTaskPrefs();
const effectiveView = useEffectiveSidebarView();
const grouped = useMemo(
() => applyView(displayTasks, effectiveView, { pinnedTaskIds, orderedTaskIds }),
[displayTasks, effectiveView, pinnedTaskIds, orderedTaskIds],
);
const { grouped, effectiveView, prefs } = useGroupedSidebarView(displayTasks);
const { pinnedTaskIds, togglePinnedTask, handleReorderGroup, handleReorderSubtasks } = prefs;
return (
<PanelRoot data-testid="task-sidebar">
<SidebarFilterBar />
@@ -646,6 +657,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
onMoveToStep={handleMoveToStep}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
onReorderSubtasks={handleReorderSubtasks}
pinnedTaskIds={pinnedTaskIds}
deletingTaskId={deletingTaskId}
isLoading={isLoadingWorkflow}
@@ -0,0 +1,140 @@
"use client";
import { Fragment, useCallback, useMemo } from "react";
import {
DndContext,
PointerSensor,
closestCenter,
type DragEndEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
import {
SortableContext,
arrayMove,
useSortable,
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { cn } from "@/lib/utils";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
function SortableSubtaskRow({ taskId, children }: { taskId: string; children: React.ReactNode }) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: taskId,
});
const sortableAttributes = {
...attributes,
role: undefined,
"aria-roledescription": undefined,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : undefined,
};
return (
<div
ref={setNodeRef}
style={style}
{...sortableAttributes}
{...listeners}
tabIndex={undefined}
data-testid="sortable-subtask-row"
data-task-id={taskId}
className={cn("cursor-grab active:cursor-grabbing", isDragging && "z-50")}
>
{children}
</div>
);
}
function useSubtaskDnd(
parentTaskId: string,
subtasks: TaskSwitcherItem[],
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void,
) {
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;
const ids = subtasks.map((t) => t.id);
const oldIndex = ids.indexOf(String(active.id));
const newIndex = ids.indexOf(String(over.id));
if (oldIndex < 0 || newIndex < 0) return;
onReorderSubtasks(parentTaskId, arrayMove(ids, oldIndex, newIndex));
},
[parentTaskId, subtasks, onReorderSubtasks],
);
const sortableIds = useMemo(() => subtasks.map((t) => t.id), [subtasks]);
return { sensors, handleDragEnd, sortableIds };
}
/**
* Per-parent sortable list. When `onReorderSubtasks` is omitted the DnD
* scaffolding (and the misleading grab cursor) is skipped — callers that
* don't reorder get a plain list with no wasted setup.
*/
export function SortableSubtaskList({
parentTaskId,
subtasks,
onReorderSubtasks,
renderRow,
}: {
parentTaskId: string;
subtasks: TaskSwitcherItem[];
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
renderRow: (sub: TaskSwitcherItem) => React.ReactNode;
}) {
if (!onReorderSubtasks) {
return (
<>
{subtasks.map((sub) => (
<Fragment key={sub.id}>{renderRow(sub)}</Fragment>
))}
</>
);
}
return (
<SortableSubtaskListInner
parentTaskId={parentTaskId}
subtasks={subtasks}
onReorderSubtasks={onReorderSubtasks}
renderRow={renderRow}
/>
);
}
function SortableSubtaskListInner({
parentTaskId,
subtasks,
onReorderSubtasks,
renderRow,
}: {
parentTaskId: string;
subtasks: TaskSwitcherItem[];
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
renderRow: (sub: TaskSwitcherItem) => React.ReactNode;
}) {
const { sensors, handleDragEnd, sortableIds } = useSubtaskDnd(
parentTaskId,
subtasks,
onReorderSubtasks,
);
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{subtasks.map((sub) => (
<SortableSubtaskRow key={sub.id} taskId={sub.id}>
{renderRow(sub)}
</SortableSubtaskRow>
))}
</SortableContext>
</DndContext>
);
}
+22 -10
View File
@@ -23,8 +23,7 @@ import { TaskItem } from "./task-item";
import { TaskItemWithContextMenu, type StepDef } from "./task-switcher-context-menu";
import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view";
import { type TaskMoveWorkflow } from "@/components/task/task-move-context-menu";
const DRAG_ACTIVATION_DISTANCE = 8;
import { DRAG_ACTIVATION_DISTANCE, SortableSubtaskList } from "./task-switcher-subtask-dnd";
export type TaskSwitcherItem = {
id: string;
@@ -73,6 +72,7 @@ type TaskSwitcherProps = {
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
pinnedTaskIds?: string[];
deletingTaskId?: string | null;
isLoading?: boolean;
@@ -283,6 +283,7 @@ type GroupSectionProps = {
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
onTogglePin?: (taskId: string) => void;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
pinnedSet: Set<string>;
deletingTaskId?: string | null;
};
@@ -318,6 +319,7 @@ function GroupTaskList({
onToggleSubtasks,
pinnedSet,
rowProps,
onReorderSubtasks,
}: {
group: SidebarGroup;
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
@@ -325,6 +327,7 @@ function GroupTaskList({
onToggleSubtasks?: (parentTaskId: string) => void;
pinnedSet: Set<string>;
rowProps: Omit<TaskRowProps, "task" | "subtaskToggle" | "isPinned" | "isSubTask">;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
}) {
return (
<>
@@ -353,14 +356,19 @@ function GroupTaskList({
/>
}
subTasks={
!subsHidden &&
subs?.map((sub) => (
// Subtasks aren't independently sortable or pinnable — pinning
// would show an icon but `floatPinnedToTop` only operates on
// root tasks, so the row wouldn't move. Drop both props to
// avoid the misleading no-op menu item.
<TaskRow key={sub.id} task={sub} isSubTask {...rowProps} onTogglePin={undefined} />
))
!subsHidden && subs && subs.length > 0 ? (
<SortableSubtaskList
parentTaskId={task.id}
subtasks={subs}
onReorderSubtasks={onReorderSubtasks}
// Subtasks aren't pinnable — pinning would show an icon but
// `floatPinnedToTop` only operates on root tasks, so the row
// wouldn't move. Drop the prop to avoid the no-op menu item.
renderRow={(sub) => (
<TaskRow task={sub} isSubTask {...rowProps} onTogglePin={undefined} />
)}
/>
) : undefined
}
/>
);
@@ -388,6 +396,7 @@ function GroupSection({
onMoveToStep,
onTogglePin,
onReorderGroup,
onReorderSubtasks,
pinnedSet,
deletingTaskId,
}: GroupSectionProps) {
@@ -432,6 +441,7 @@ function GroupSection({
onToggleSubtasks={onToggleSubtasks}
pinnedSet={pinnedSet}
rowProps={rowProps}
onReorderSubtasks={onReorderSubtasks}
/>
</SortableContext>
</DndContext>
@@ -457,6 +467,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
onMoveToStep,
onTogglePin,
onReorderGroup,
onReorderSubtasks,
pinnedTaskIds,
deletingTaskId,
isLoading = false,
@@ -497,6 +508,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
onMoveToStep={onMoveToStep}
onTogglePin={onTogglePin}
onReorderGroup={onReorderGroup}
onReorderSubtasks={onReorderSubtasks}
pinnedSet={pinnedSet}
deletingTaskId={deletingTaskId}
/>
@@ -9,13 +9,18 @@ import { mergeGroupOrder } from "@/lib/sidebar/apply-view";
* "Custom" via the view draft — read draft-first so subsequent drags don't
* try to re-set custom on top of an already-custom draft. Both desktop and
* mobile sidebars use this hook to stay in sync.
*
* `handleReorderSubtasks` is intentionally scoped to one parent: it writes to
* `subtaskOrderByParentId` only, so the root-task sort is never disturbed.
*/
export function useSidebarTaskPrefs() {
const store = useAppStoreApi();
const pinnedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.pinnedTaskIds);
const orderedTaskIds = useAppStore((s) => s.sidebarTaskPrefs.orderedTaskIds);
const subtaskOrderByParentId = useAppStore((s) => s.sidebarTaskPrefs.subtaskOrderByParentId);
const togglePinnedTask = useAppStore((s) => s.togglePinnedTask);
const setSidebarTaskOrder = useAppStore((s) => s.setSidebarTaskOrder);
const setSubtaskOrder = useAppStore((s) => s.setSubtaskOrder);
const updateSidebarDraft = useAppStore((s) => s.updateSidebarDraft);
const handleReorderGroup = useCallback(
@@ -33,5 +38,19 @@ export function useSidebarTaskPrefs() {
[store, setSidebarTaskOrder, updateSidebarDraft],
);
return { pinnedTaskIds, orderedTaskIds, togglePinnedTask, handleReorderGroup };
const handleReorderSubtasks = useCallback(
(parentTaskId: string, orderedSubtaskIds: string[]) => {
setSubtaskOrder(parentTaskId, orderedSubtaskIds);
},
[setSubtaskOrder],
);
return {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
togglePinnedTask,
handleReorderGroup,
handleReorderSubtasks,
};
}
+47
View File
@@ -580,6 +580,11 @@ export function cleanupTaskStorage(
setStoredOrderedTaskIds(ordered.filter((id) => id !== taskId));
}
// Per-parent subtask order: drop the deleted task as a parent key, and strip
// it from any other parent's subtask-order list (in case it was a subtask).
const subOrder = getStoredSubtaskOrderByParentId();
if (pruneSubtaskOrder(subOrder, taskId)) setStoredSubtaskOrderByParentId(subOrder);
// Env-keyed storage — dockview layout + maximize live under task envs.
for (const envId of envIds) {
removeEnvMaximizeState(envId);
@@ -697,6 +702,48 @@ export function setStoredOrderedTaskIds(ids: string[]): void {
setLocalStorage(SIDEBAR_TASK_ORDER_KEY, ids);
}
const SIDEBAR_SUBTASK_ORDER_KEY = "kandev.sidebar.subtaskOrderByParentId";
export function getStoredSubtaskOrderByParentId(): Record<string, string[]> {
const raw = getLocalStorage<Record<string, string[]>>(SIDEBAR_SUBTASK_ORDER_KEY, {}) as unknown;
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
const out: Record<string, string[]> = {};
for (const [parentId, ids] of Object.entries(raw as Record<string, unknown>)) {
if (typeof parentId !== "string" || !Array.isArray(ids)) continue;
const filtered = ids.filter((id): id is string => typeof id === "string");
if (filtered.length > 0) out[parentId] = filtered;
}
return out;
}
export function setStoredSubtaskOrderByParentId(map: Record<string, string[]>): void {
setLocalStorage(SIDEBAR_SUBTASK_ORDER_KEY, map);
}
/**
* Strip a task id from a subtask-order map: drop it as a parent key, and
* remove it from every other parent's subtask list (cleaning up the parent
* entry if its list becomes empty). Mutates `map` in place and returns
* `true` if anything changed. Used by both `cleanupTaskStorage` (plain
* object) and `removeTaskFromSidebarPrefs` (Immer draft) to keep the two
* cleanup paths in lockstep.
*/
export function pruneSubtaskOrder(map: Record<string, string[]>, taskId: string): boolean {
let changed = false;
if (taskId in map) {
delete map[taskId];
changed = true;
}
for (const [parentId, ids] of Object.entries(map)) {
if (!ids.includes(taskId)) continue;
const next = ids.filter((id) => id !== taskId);
if (next.length === 0) delete map[parentId];
else map[parentId] = next;
changed = true;
}
return changed;
}
// --- Sidebar collapsed subtask parents (sessionStorage, tab-scoped) ---
const COLLAPSED_SUBTASKS_KEY = "kandev.sidebar.collapsedSubtasks";
+72 -5
View File
@@ -455,17 +455,16 @@ describe("applyView — custom sort", () => {
task({ id: "c1", title: "C1", parentTaskId: "p1", createdAt: "2026-02-01" }),
task({ id: "c2", title: "C2", parentTaskId: "p1", createdAt: "2026-02-02" }),
];
// Simulates a drag of P3 to the front: handleReorderGroup writes only
// root IDs into orderedTaskIds.
// Simulates a drag of P3 to the front. orderedTaskIds contains only root
// IDs here; subtasks fall back to "newest createdAt first".
const out = applyView(tasks, customView, {
pinnedTaskIds: [],
orderedTaskIds: ["p3", "p1", "p2"],
});
// Root order matches the drag.
expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p3", "p1", "p2"]);
// Subtasks stay attached to p1 — they're never in orderedTaskIds and they
// never appear in group.tasks, so dragging the parent doesn't separate
// them or reorder them relative to each other.
// Subtasks stay attached to p1 and use the createdAt fallback (newest
// first) since neither is in orderedTaskIds.
expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["c2", "c1"]);
expect(out.subTasksByParentId.get("p2")).toBeUndefined();
expect(out.subTasksByParentId.get("p3")).toBeUndefined();
@@ -486,6 +485,74 @@ describe("applyView — custom sort", () => {
});
});
describe("applyView — subtaskOrderByParentId", () => {
// Subtask ordering is independent of the view's sort: the global sort still
// determines root order, and per-parent overrides reorder *only* that parent's
// subtask list.
const titleAscView: SidebarView = {
id: "v",
name: "v",
filters: [],
sort: { key: "title", direction: "asc" },
group: "none",
collapsedGroups: [],
};
const parentId = "p1";
const sub = (id: string, parent: string) => task({ id, title: id, parentTaskId: parent });
it("reorders one parent's subtasks without flipping the root sort", () => {
const tasks = [
task({ id: "p1", title: "P1" }),
task({ id: "p2", title: "P2" }),
sub("c1", parentId),
sub("c2", parentId),
sub("c3", parentId),
];
const out = applyView(tasks, titleAscView, {
pinnedTaskIds: [],
orderedTaskIds: [],
subtaskOrderByParentId: { [parentId]: ["c1", "c3", "c2"] },
});
// Root order still follows title asc — untouched by the subtask reorder.
expect(out.groups[0].tasks.map((t) => t.id)).toEqual(["p1", "p2"]);
expect(out.subTasksByParentId.get(parentId)?.map((t) => t.id)).toEqual(["c1", "c3", "c2"]);
});
it("subtasks not in the override keep the active sort's order, after the listed ones", () => {
const tasks = [
task({ id: "p1", title: "P1" }),
sub("c1", parentId),
sub("c2", parentId),
sub("c3", parentId),
];
// Title-asc fallback over the unordered subtasks: c1, c2 follow after c3.
const out = applyView(tasks, titleAscView, {
pinnedTaskIds: [],
orderedTaskIds: [],
subtaskOrderByParentId: { [parentId]: ["c3"] },
});
expect(out.subTasksByParentId.get(parentId)?.map((t) => t.id)).toEqual(["c3", "c1", "c2"]);
});
it("override for parent A does not affect parent B's subtasks", () => {
const tasks = [
task({ id: "p1", title: "P1" }),
task({ id: "p2", title: "P2" }),
sub("a1", "p1"),
sub("a2", "p1"),
sub("b1", "p2"),
sub("b2", "p2"),
];
const out = applyView(tasks, titleAscView, {
pinnedTaskIds: [],
orderedTaskIds: [],
subtaskOrderByParentId: { p1: ["a2", "a1"] },
});
expect(out.subTasksByParentId.get("p1")?.map((t) => t.id)).toEqual(["a2", "a1"]);
expect(out.subTasksByParentId.get("p2")?.map((t) => t.id)).toEqual(["b1", "b2"]);
});
});
describe("mergeGroupOrder", () => {
it("appends when no group items are in current", () => {
expect(mergeGroupOrder([], ["a", "b"])).toEqual(["a", "b"]);
+31
View File
@@ -26,6 +26,7 @@ export type GroupedSidebarList = {
export type SidebarTaskPrefs = {
pinnedTaskIds: string[];
orderedTaskIds: string[];
subtaskOrderByParentId?: Record<string, string[]>;
};
type DimensionExtractor = (task: TaskSwitcherItem) => FilterValue | undefined;
@@ -310,6 +311,28 @@ function buildIndex(ids: string[]): Map<string, number> {
return m;
}
/**
* Sort subtasks of a single parent by the user's manual order. Listed
* subtasks come first in their stored order; unlisted ones keep their incoming
* order (which reflects the active sort) afterwards.
*/
function applySubtaskOrder(
subtasks: TaskSwitcherItem[],
orderedSubtaskIds: string[],
): TaskSwitcherItem[] {
const orderIndex = buildIndex(orderedSubtaskIds);
const withSortIndex = subtasks.map((t, i) => ({ t, sortIdx: i }));
withSortIndex.sort((a, b) => {
const ai = orderIndex.get(a.t.id);
const bi = orderIndex.get(b.t.id);
if (ai !== undefined && bi !== undefined) return ai - bi;
if (ai !== undefined) return -1;
if (bi !== undefined) return 1;
return a.sortIdx - b.sortIdx;
});
return withSortIndex.map((x) => x.t);
}
export function applyView(
tasks: TaskSwitcherItem[],
view: SidebarView,
@@ -318,6 +341,14 @@ export function applyView(
const filtered = applyFilters(tasks, view.filters);
const sorted = applySort(filtered, view.sort, prefs?.orderedTaskIds);
const grouped = applyGroup(sorted, view.group);
const subOrderMap = prefs?.subtaskOrderByParentId;
if (subOrderMap) {
for (const [parentId, orderedIds] of Object.entries(subOrderMap)) {
const subs = grouped.subTasksByParentId.get(parentId);
if (!subs) continue;
grouped.subTasksByParentId.set(parentId, applySubtaskOrder(subs, orderedIds));
}
}
if (!prefs || prefs.pinnedTaskIds.length === 0) return grouped;
const pinnedSet = new Set(prefs.pinnedTaskIds);
const pinIndex = buildIndex(prefs.pinnedTaskIds);
@@ -1,4 +1,9 @@
import { setStoredOrderedTaskIds, setStoredPinnedTaskIds } from "@/lib/local-storage";
import {
pruneSubtaskOrder,
setStoredOrderedTaskIds,
setStoredPinnedTaskIds,
setStoredSubtaskOrderByParentId,
} from "@/lib/local-storage";
import type { UISlice } from "./types";
type ImmerSet = (recipe: (draft: UISlice) => void, shouldReplace?: false | undefined) => void;
@@ -18,6 +23,13 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet) {
draft.sidebarTaskPrefs.orderedTaskIds = orderedTaskIds;
setStoredOrderedTaskIds(orderedTaskIds);
}),
setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) =>
set((draft) => {
const map = draft.sidebarTaskPrefs.subtaskOrderByParentId;
if (orderedSubtaskIds.length === 0) delete map[parentTaskId];
else map[parentTaskId] = orderedSubtaskIds;
setStoredSubtaskOrderByParentId(map);
}),
removeTaskFromSidebarPrefs: (taskId: string) =>
set((draft) => {
const prefs = draft.sidebarTaskPrefs;
@@ -31,6 +43,9 @@ export function buildSidebarTaskPrefsActions(set: ImmerSet) {
prefs.orderedTaskIds.splice(orderIdx, 1);
setStoredOrderedTaskIds(prefs.orderedTaskIds);
}
if (pruneSubtaskOrder(prefs.subtaskOrderByParentId, taskId)) {
setStoredSubtaskOrderByParentId(prefs.subtaskOrderByParentId);
}
}),
};
}
+12 -3
View File
@@ -106,6 +106,12 @@ export type SidebarTaskPrefsState = {
pinnedTaskIds: string[];
/** Manual order. Tasks not present fall back to the active sort. */
orderedTaskIds: string[];
/**
* Per-parent subtask order. Keyed by parent task id; value is the ordered
* subtask ids. Subtasks not listed fall back to the active sort.
* Independent of the global `orderedTaskIds` and the view's sort spec.
*/
subtaskOrderByParentId: Record<string, string[]>;
};
export type UISliceState = {
@@ -183,10 +189,13 @@ export type UISliceActions = {
setKanbanPreviewedTaskId: (taskId: string | null) => void;
togglePinnedTask: (taskId: string) => void;
setSidebarTaskOrder: (orderedTaskIds: string[]) => void;
/** Replace the stored subtask order for a parent task. Empty array clears it. */
setSubtaskOrder: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
/**
* Drop a task ID from both pinned and ordered arrays in-memory and persist.
* Called on task deletion so the in-memory state doesn't out-of-date the
* already-cleaned localStorage and silently re-write the deleted ID back.
* Drop a task ID from pinned, ordered, and subtask-order arrays in-memory
* and persist. Called on task deletion so the in-memory state doesn't
* out-of-date the already-cleaned localStorage and silently re-write the
* deleted ID back.
*/
removeTaskFromSidebarPrefs: (taskId: string) => void;
};
@@ -143,6 +143,76 @@ describe("sidebar task prefs (pin + manual order)", () => {
});
});
describe("setSubtaskOrder", () => {
const SUB_KEY = "kandev.sidebar.subtaskOrderByParentId";
const PARENT_A = "parent-a";
const PARENT_B = "parent-b";
beforeEach(() => {
window.localStorage.clear();
});
it("hydrates subtaskOrderByParentId from localStorage", () => {
window.localStorage.setItem(SUB_KEY, JSON.stringify({ [PARENT_A]: ["c1", "c2"] }));
const store = makeStore();
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
[PARENT_A]: ["c1", "c2"],
});
});
it("setSubtaskOrder writes per-parent order and persists", () => {
const store = makeStore();
store.getState().setSubtaskOrder(PARENT_A, ["c2", "c1"]);
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
[PARENT_A]: ["c2", "c1"],
});
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({
[PARENT_A]: ["c2", "c1"],
});
store.getState().setSubtaskOrder(PARENT_B, ["d1"]);
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
[PARENT_A]: ["c2", "c1"],
[PARENT_B]: ["d1"],
});
});
it("setSubtaskOrder with empty array clears the parent entry", () => {
const store = makeStore();
store.getState().setSubtaskOrder(PARENT_A, ["c1"]);
store.getState().setSubtaskOrder(PARENT_A, []);
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({});
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({});
});
it("removeTaskFromSidebarPrefs drops the task as a parent key and from sibling lists", () => {
const store = makeStore();
store.getState().setSubtaskOrder(PARENT_A, ["c1", "c2"]);
store.getState().setSubtaskOrder(PARENT_B, ["c1", "d1"]);
// Removing c1 — it's both a subtask under p1 and p2.
store.getState().removeTaskFromSidebarPrefs("c1");
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
[PARENT_A]: ["c2"],
[PARENT_B]: ["d1"],
});
// Removing the parent itself wipes its entry.
store.getState().removeTaskFromSidebarPrefs(PARENT_A);
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({
[PARENT_B]: ["d1"],
});
});
it("removeTaskFromSidebarPrefs drops the parent key if removing its last subtask", () => {
const store = makeStore();
store.getState().setSubtaskOrder(PARENT_A, ["c1"]);
store.getState().removeTaskFromSidebarPrefs("c1");
expect(store.getState().sidebarTaskPrefs.subtaskOrderByParentId).toEqual({});
expect(JSON.parse(window.localStorage.getItem(SUB_KEY) ?? "null")).toEqual({});
});
});
describe("reorderSidebarViews", () => {
beforeEach(() => {
window.localStorage.clear();
+3 -1
View File
@@ -7,6 +7,7 @@ import {
getStoredSidebarActiveViewId,
getStoredSidebarDraft,
getStoredSidebarUserViews,
getStoredSubtaskOrderByParentId,
removeStoredSidebarDraft,
setLocalStorage,
setStoredCollapsedSubtaskParents,
@@ -120,7 +121,7 @@ export const defaultUIState: UISliceState = {
sidebarViews: loadSidebarState(),
collapsedSubtaskParents: [],
kanbanPreviewedTaskId: null,
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [] },
sidebarTaskPrefs: { pinnedTaskIds: [], orderedTaskIds: [], subtaskOrderByParentId: {} },
};
type ImmerSet = Parameters<typeof createUISlice>[0];
@@ -524,6 +525,7 @@ export const createUISlice: StateCreator<UISlice, [["zustand/immer", never]], []
sidebarTaskPrefs: {
pinnedTaskIds: getStoredPinnedTaskIds(),
orderedTaskIds: getStoredOrderedTaskIds(),
subtaskOrderByParentId: getStoredSubtaskOrderByParentId(),
},
...buildPreviewActions(set),
...buildMobileActions(set),
+1
View File
@@ -478,6 +478,7 @@ export type AppState = {
setKanbanPreviewedTaskId: UIA["setKanbanPreviewedTaskId"];
togglePinnedTask: UIA["togglePinnedTask"];
setSidebarTaskOrder: UIA["setSidebarTaskOrder"];
setSubtaskOrder: UIA["setSubtaskOrder"];
removeTaskFromSidebarPrefs: UIA["removeTaskFromSidebarPrefs"];
// Office actions
setOfficeAgentProfiles: (agents: AgentProfile[]) => void;