From 35242b2982bb58d7e87c34d3f2901d8d07a4f349 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Thu, 21 May 2026 01:31:42 +0100 Subject: [PATCH] feat(web): drag-reorder subtasks within a parent task (#962) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../mobile/session-task-switcher-sheet.tsx | 20 ++- .../components/task/task-session-sidebar.tsx | 28 +++- .../task/task-switcher-subtask-dnd.tsx | 140 ++++++++++++++++++ apps/web/components/task/task-switcher.tsx | 32 ++-- .../domains/sidebar/use-sidebar-task-prefs.ts | 21 ++- apps/web/lib/local-storage.ts | 47 ++++++ apps/web/lib/sidebar/apply-view.test.ts | 77 +++++++++- apps/web/lib/sidebar/apply-view.ts | 31 ++++ .../slices/ui/sidebar-task-prefs-actions.ts | 17 ++- apps/web/lib/state/slices/ui/types.ts | 15 +- apps/web/lib/state/slices/ui/ui-slice.test.ts | 70 +++++++++ apps/web/lib/state/slices/ui/ui-slice.ts | 4 +- apps/web/lib/state/store.ts | 1 + 13 files changed, 470 insertions(+), 33 deletions(-) create mode 100644 apps/web/components/task/task-switcher-subtask-dnd.tsx diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 88d710e5fe..73b4bf20f5 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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 ( + 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 ( @@ -646,6 +657,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ onMoveToStep={handleMoveToStep} onTogglePin={togglePinnedTask} onReorderGroup={handleReorderGroup} + onReorderSubtasks={handleReorderSubtasks} pinnedTaskIds={pinnedTaskIds} deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} diff --git a/apps/web/components/task/task-switcher-subtask-dnd.tsx b/apps/web/components/task/task-switcher-subtask-dnd.tsx new file mode 100644 index 0000000000..030eb723ed --- /dev/null +++ b/apps/web/components/task/task-switcher-subtask-dnd.tsx @@ -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 ( +
+ {children} +
+ ); +} + +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) => ( + {renderRow(sub)} + ))} + + ); + } + return ( + + ); +} + +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 ( + + + {subtasks.map((sub) => ( + + {renderRow(sub)} + + ))} + + + ); +} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index b0c75b83a9..abbedc16fa 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -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; deletingTaskId?: string | null; }; @@ -318,6 +319,7 @@ function GroupTaskList({ onToggleSubtasks, pinnedSet, rowProps, + onReorderSubtasks, }: { group: SidebarGroup; subTasksByParentId: Map; @@ -325,6 +327,7 @@ function GroupTaskList({ onToggleSubtasks?: (parentTaskId: string) => void; pinnedSet: Set; rowProps: Omit; + 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. - - )) + !subsHidden && subs && subs.length > 0 ? ( + ( + + )} + /> + ) : 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} /> @@ -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} /> diff --git a/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts b/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts index 32b94604a6..048291d95a 100644 --- a/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts +++ b/apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts @@ -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, + }; } diff --git a/apps/web/lib/local-storage.ts b/apps/web/lib/local-storage.ts index 30cdcb2254..ce93b6bdaf 100644 --- a/apps/web/lib/local-storage.ts +++ b/apps/web/lib/local-storage.ts @@ -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 { + const raw = getLocalStorage>(SIDEBAR_SUBTASK_ORDER_KEY, {}) as unknown; + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {}; + const out: Record = {}; + for (const [parentId, ids] of Object.entries(raw as Record)) { + 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): 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, 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"; diff --git a/apps/web/lib/sidebar/apply-view.test.ts b/apps/web/lib/sidebar/apply-view.test.ts index e1cfa998f0..abe1908588 100644 --- a/apps/web/lib/sidebar/apply-view.test.ts +++ b/apps/web/lib/sidebar/apply-view.test.ts @@ -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"]); diff --git a/apps/web/lib/sidebar/apply-view.ts b/apps/web/lib/sidebar/apply-view.ts index 524679b34c..86a1f45917 100644 --- a/apps/web/lib/sidebar/apply-view.ts +++ b/apps/web/lib/sidebar/apply-view.ts @@ -26,6 +26,7 @@ export type GroupedSidebarList = { export type SidebarTaskPrefs = { pinnedTaskIds: string[]; orderedTaskIds: string[]; + subtaskOrderByParentId?: Record; }; type DimensionExtractor = (task: TaskSwitcherItem) => FilterValue | undefined; @@ -310,6 +311,28 @@ function buildIndex(ids: string[]): Map { 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); diff --git a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts index 4090e5c4b2..63a02688dc 100644 --- a/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts +++ b/apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts @@ -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); + } }), }; } diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 9765e5c872..d7a1d45e42 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -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; }; 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; }; 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 75b27f5c54..4b379572ed 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.test.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.test.ts @@ -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(); diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index a5a6d23106..574be087bd 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -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[0]; @@ -524,6 +525,7 @@ export const createUISlice: StateCreator void;