Skip to content

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

Merged
jcfs merged 4 commits into
mainfrom
feature/order-subtasks-insid-bg4
May 21, 2026
Merged

feat(web): drag-reorder subtasks within a parent task#962
jcfs merged 4 commits into
mainfrom
feature/order-subtasks-insid-bg4

Conversation

@jcfs
Copy link
Copy Markdown
Contributor

@jcfs jcfs commented May 19, 2026

Users wanted to control the order of subtasks under a parent in the sidebar without it forcing the whole sidebar into "Custom" sort. Subtasks are now drag-reorderable per parent, with the override scoped to that parent and the global sort untouched.

Important Changes

  • New sidebarTaskPrefs.subtaskOrderByParentId: Record<parentId, ids[]> slice field + setSubtaskOrder action, persisted to localStorage under kandev.sidebar.subtaskOrderByParentId.
  • applyView applies the override after separateSubtasks (listed subtasks first in user order, rest keep the active sort's order).
  • TaskSwitcher adds an onReorderSubtasks(parentId, ids) callback; each parent's subtask list wraps in its own nested DndContext / SortableContext so subtask drags don't trigger root reorder.
  • Task-deletion cleanup strips the id from subtaskOrderByParentId both as a parent key and from any sibling's list (slice action + cleanupTaskStorage).

Validation

  • pnpm --filter @kandev/web test run — 73/73 affected tests pass (slice action persistence/cleanup, applyView per-parent override, isolation across parents, partial-override fallback).
  • pnpm --filter @kandev/web lint — clean.
  • pnpm --filter @kandev/web exec tsc --noEmit — clean (only pre-existing @/generated/* errors that exist on main too).
  • make -C apps/backend test lint — clean (touched no backend code, run as part of /verify).

Possible Improvements

Low risk: ordering lives entirely in localStorage and is keyed on task id, so a stale entry survives only until task deletion. The applied override skips parents with no entry, so non-users see no change.

Checklist

  • I have performed a self-review of my code.
  • I have manually tested my changes and they work as expected.
  • My changes have tests that cover the new functionality and edge cases.
  • If my change touches UI files (apps/web/), I have added or updated Playwright e2e tests in apps/web/e2e/ and verified them with make test-e2e.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Review Change Stack

📝 Walkthrough

Walkthrough

Adds per-parent subtask reordering: new UI state and actions, localStorage persistence and pruning, apply-view ordering, a SortableSubtaskList DnD component using @dnd-kit, and wiring the reorder handler through sidebar and mobile task switchers.

Changes

Subtask drag-and-drop reordering

Layer / File(s) Summary
State types and actions for per-parent subtask ordering
apps/web/lib/state/slices/ui/types.ts, apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts, apps/web/lib/state/slices/ui/ui-slice.ts, apps/web/lib/state/store.ts, apps/web/lib/state/slices/ui/ui-slice.test.ts
Adds subtaskOrderByParentId to sidebar prefs state; adds setSubtaskOrder(parentTaskId, orderedSubtaskIds) action that persists to localStorage; extends task-removal to prune subtask-order entries; hydrates slice from storage; tests cover mutations and persistence.
localStorage persistence for subtask ordering
apps/web/lib/local-storage.ts
New getStoredSubtaskOrderByParentId, setStoredSubtaskOrderByParentId, and pruneSubtaskOrder helpers; cleanupTaskStorage prunes deleted task IDs from the per-parent map.
View logic for per-parent subtask reordering
apps/web/lib/sidebar/apply-view.ts, apps/web/lib/sidebar/apply-view.test.ts
Adds optional subtaskOrderByParentId to SidebarTaskPrefs; implements applySubtaskOrder to place listed subtasks first while preserving unlisted order; applyView applies per-parent overrides to grouped.subTasksByParentId before pinned-task logic; tests verify parent-scoped behavior and fallbacks.
DnD component for sortable subtasks
apps/web/components/task/task-switcher-subtask-dnd.tsx
New SortableSubtaskList and SortableSubtaskRow using @dnd-kit; useSubtaskDnd configures a pointer sensor with 8px activation and computes reorders on drag-end via arrayMove; list renders without DnD when no reorder handler provided.
Hook for sidebar task preference access
apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts
useSidebarTaskPrefs now exposes subtaskOrderByParentId and a handleReorderSubtasks(parentTaskId, orderedSubtaskIds) callback that calls the UI action to persist per-parent ordering.
Component wiring for subtask reordering
apps/web/components/task/task-switcher.tsx, apps/web/components/task/task-session-sidebar.tsx, apps/web/components/task/mobile/session-task-switcher-sheet.tsx
TaskSwitcher gains onReorderSubtasks? prop and renders subtasks via SortableSubtaskList; sidebar introduces useGroupedSidebarView and passes handleReorderSubtasks into TaskSwitcher; mobile sheet also forwards the handler.
sequenceDiagram
  participant Sidebar as TaskSessionSidebar
  participant Prefs as useSidebarTaskPrefs
  participant Switcher as TaskSwitcher
  participant List as SortableSubtaskList
  participant Storage as LocalStorage

  Sidebar->>Prefs: read handleReorderSubtasks
  Sidebar->>Switcher: pass onReorderSubtasks(handleReorderSubtasks)
  Switcher->>List: render with onReorderSubtasks
  List->>Switcher: onDragEnd -> onReorderSubtasks(parentId, orderedIds)
  Switcher->>Prefs: invoke handleReorderSubtasks(parentId, orderedIds)
  Prefs->>Storage: setStoredSubtaskOrderByParentId(updatedMap)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • kdlbs/kandev#829: Both PRs extend the sidebar pin/manual ordering architecture; this PR adds per-parent subtask ordering and DnD on top of that foundation.

Poem

🐰 Subtasks now dance in drag-and-drop delight,
Parents keep order, each move feels right,
State and storage keep every hop,
Components pass the handler non-stop,
A rabbit’s tidy reorder — hop! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.32% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely summarizes the main feature: drag-reordering subtasks within a parent task, which is the primary change across all modified files.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The PR description covers all required sections: summary, important changes, validation with specific test/lint commands, possible improvements noting low risk, and the required checklist.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/order-subtasks-insid-bg4

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Claude finished @jcfs's task in 3m 51s —— View job


Code Review Complete

  • Gather context & read skill file
  • Read changed files
  • Analyze code quality, correctness, and edge cases
  • Post inline comments (3)
  • Post findings summary

The feature is well-implemented with solid test coverage (73 tests). Three suggestions posted — all non-blocking:

  1. Duplicate cleanup looplocal-storage.ts:585-598 and sidebar-task-prefs-actions.ts:45-58 both implement the same subtask-order cleanup logic. Extract a shared helper to prevent silent divergence.
  2. Unconditional DnD setupSortableSubtaskList mounts DndContext/SortableContext even when onReorderSubtasks is undefined, showing a misleading cursor-grab on subtask rows with no reorder outcome.
  3. File lengthtask-switcher.tsx is now 603 lines (3 over the 600-line soft limit, currently a warning). The new subtask DnD components (~80 lines) are natural extraction candidates.

Comment thread apps/web/lib/local-storage.ts Outdated
Comment thread apps/web/components/task/task-switcher.tsx Outdated
Comment thread apps/web/components/task/task-switcher.tsx Outdated
@claude
Copy link
Copy Markdown

claude Bot commented May 19, 2026

Findings

Suggestion (recommended, doesn't block)

  1. Duplicated subtask cleanup loopapps/web/lib/local-storage.ts:585-598 and apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts:45-58

    • Issue: Both sites implement the same two-pass cleanup: delete the task as a parent key, then scrub it from every sibling list. Only the target (localStorage value vs. Immer draft) differs.
    • Why: Subtle divergence over time — e.g., if an early-exit condition is added to one site, the other stays broken.
    • Fix: Extract a pruneSubtaskOrder(map, taskId) helper in local-storage.ts and delegate both sites to it. See inline comment at local-storage.ts:598.
  2. SortableSubtaskList always mounts DnD infrastructure, even when onReorderSubtasks is undefinedapps/web/components/task/task-switcher.tsx:323-350

    • Issue: When the callback isn't provided, subtask rows render with cursor-grab and a live DndContext / SortableContext but any completed drag is silently dropped.
    • Why: Misleading interaction for future call sites that omit the prop; wastes a DnD tree setup per parent.
    • Fix: Guard with if (!onReorderSubtasks) return plain list. See inline comment at line 349.
  3. task-switcher.tsx is 603 lines, 3 over the 600-line soft limitapps/web/components/task/task-switcher.tsx

    • Issue: Currently a warning in ESLint (apps/web/eslint.config.mjs), scheduled to become an error.
    • Why: The limit is designed to keep components decomposed; the file is trending upward.
    • Fix: Extract SortableSubtaskRow / SortableSubtaskList / useSubtaskDnd (~80 lines) to a sibling file — they only depend on the shared DRAG_ACTIVATION_DISTANCE constant and dnd-kit imports. See inline comment at line 296.

Summary

Severity Count
Blocker 0
Suggestion 3

Verdict: Ready to merge — no blockers. The three suggestions are low-urgency clean-up items that can land as a follow-up if preferred.

Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No issues found across 12 files

Re-trigger cubic

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 19, 2026

Greptile Summary

This PR adds per-parent drag-reorder for subtasks in the sidebar, storing the order in localStorage under a new subtaskOrderByParentId record scoped to each parent. Reordering is isolated to subtasks and never touches the global sort or root-task order.

  • New setSubtaskOrder Zustand action persists per-parent subtask order; applyView applies it post-sort so root order and global view sort remain untouched.
  • SortableSubtaskList introduces a nested DndContext / SortableContext per parent, correctly skipping DnD setup (and the grab cursor) when onReorderSubtasks is absent.
  • Task deletion cleans up both the parent key and any sibling references via pruneSubtaskOrder, called from both cleanupTaskStorage (localStorage path) and removeTaskFromSidebarPrefs (in-memory Zustand path).

Confidence Score: 5/5

Safe to merge — the change is localStorage-only, scoped entirely to per-parent subtask ordering, and leaves the global sort and root-task order untouched.

The implementation is consistent with existing patterns throughout the codebase (sensor setup, Immer draft mutations, localStorage persistence). Cleanup on task deletion is handled symmetrically via two paths that are already used for pinned and ordered task IDs. The applySubtaskOrder logic is straightforward and its partial-override fallback is covered by tests. No data loss or state corruption paths were identified.

No files require special attention.

Important Files Changed

Filename Overview
apps/web/components/task/task-switcher-subtask-dnd.tsx New file: per-parent sortable subtask list with nested DndContext; correctly skips all DnD scaffolding (and grab cursor) when onReorderSubtasks is omitted.
apps/web/lib/sidebar/apply-view.ts Adds applySubtaskOrder helper and wires it into applyView after separateSubtasks; listed subtasks come first in stored order, unlisted fall back to the active sort — logic is correct and isolated from pinning and root-task ordering.
apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts Adds setSubtaskOrder action (writes per-parent map, deletes key on empty array) and extends removeTaskFromSidebarPrefs to call pruneSubtaskOrder on the Immer draft; consistent with existing pin/order patterns.
apps/web/lib/local-storage.ts Adds validated getStoredSubtaskOrderByParentId (guards against corrupt values), setStoredSubtaskOrderByParentId, and the shared pruneSubtaskOrder mutator; cleanupTaskStorage wired correctly.
apps/web/lib/sidebar/apply-view.test.ts New describe block covers per-parent override, partial-override fallback, and cross-parent isolation; existing custom-sort test updated to reflect new subtask fallback behaviour.
apps/web/lib/state/slices/ui/ui-slice.test.ts Good coverage: hydration from localStorage, setSubtaskOrder persistence, empty-array cleanup, and removeTaskFromSidebarPrefs parent-key and sibling-list pruning all tested.
apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts Exposes subtaskOrderByParentId and handleReorderSubtasks; handleReorderSubtasks is intentionally a thin wrapper that never touches the global view sort.
apps/web/components/task/task-session-sidebar.tsx Extracts useGroupedSidebarView hook to pass subtaskOrderByParentId to applyView and threads handleReorderSubtasks down to TaskSwitcher cleanly.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User drags subtask] --> B[SortableSubtaskRow\nPointerSensor activates\n≥8px movement]
    B --> C[handleDragEnd\narrayMove ids]
    C --> D[onReorderSubtasks\nparentTaskId, newIds]
    D --> E[setSubtaskOrder\nZustand action]
    E --> F[Immer draft update\nsubtaskOrderByParentId]
    E --> G[setStoredSubtaskOrderByParentId\nlocalStorage write]
    F --> H[useMemo recomputes\napplyView]
    H --> I[applySubtaskOrder\nlisted first, unlisted keep sort]
    I --> J[Updated subTasksByParentId\nre-rendered list]

    K[Task deleted] --> L[cleanupTaskStorage\nlocalStorage path]
    K --> M[removeTaskFromSidebarPrefs\nZustand path]
    L --> N[pruneSubtaskOrder\nplain object]
    M --> O[pruneSubtaskOrder\nImmer draft]
    N --> P[localStorage updated]
    O --> Q[In-memory + localStorage updated]
Loading

Reviews (4): Last reviewed commit: "refactor(web): single-source DRAG_ACTIVA..." | Re-trigger Greptile

Comment thread apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts
Kandev Agent added 2 commits May 21, 2026 00:17
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.
- 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.
@jcfs jcfs force-pushed the feature/order-subtasks-insid-bg4 branch from db8c2df to ebbba6a Compare May 20, 2026 23:20
@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

Claude finished @jcfs's task in 2m 47s —— View job


Code Review

  • Gather context
  • Read review skill
  • Inspect changed files
  • Post inline findings (none — no issues found)
  • Post findings summary

All three suggestions from the initial review have been addressed in the follow-up commit (ebbba6a). No new issues found. Verdict: ready to merge.

@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

Findings

All three suggestions from the initial review have been addressed:

  • DeduplicationpruneSubtaskOrder(map, taskId) extracted to local-storage.ts; both cleanupTaskStorage and removeTaskFromSidebarPrefs delegate to it. ✓
  • DnD guardSortableSubtaskList returns a plain row map when onReorderSubtasks is undefined; no misleading grab cursor or wasted DnD setup for non-reorderable callers. ✓
  • File lengthSortableSubtaskRow, SortableSubtaskListInner, and useSubtaskDnd extracted to task-switcher-subtask-dnd.tsx; task-switcher.tsx is now 520 lines, well under the limit. ✓

No new issues found in the follow-up commit. A few notes for completeness:

  • handleReorderSubtasks is a one-liner passthrough over setSubtaskOrder. Could be simplified to const handleReorderSubtasks = setSubtaskOrder (Zustand actions are stable references), but wrapping in useCallback is consistent with the existing handleReorderGroup pattern — not a concern.
  • AccessibilitySortableSubtaskRow deliberately strips role, aria-roledescription, and tabIndex from the dnd-kit sortable attributes, disabling keyboard drag. Intentional tradeoff; no impact on existing functionality.
  • applySubtaskOrder edge cases all handled: empty inputs, stale IDs in the override, partial overrides, multi-parent isolation — all covered by the new tests.

Summary

Severity Count
Blocker 0
Suggestion 0

Verdict: Ready to merge.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/web/components/task/task-switcher-subtask-dnd.tsx`:
- Around line 94-96: The non-DnD render path maps subtasks without keys, causing
unstable reconciliation; update the branch where onReorderSubtasks is falsy so
each mapped child has a stable key (use the subtask unique id field such as
sub.id or sub._id). Either add the key to the element returned by renderRow(sub)
or wrap renderRow(sub) in a keyed fragment (e.g., <React.Fragment
key={sub.id}>), ensuring you reference the subtasks array and renderRow function
so keys are derived from a stable identifier rather than array index.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: aecbd9ba-54e2-416c-bb10-a1e33fdc88fb

📥 Commits

Reviewing files that changed from the base of the PR and between 474346b and ebbba6a.

📒 Files selected for processing (13)
  • apps/web/components/task/mobile/session-task-switcher-sheet.tsx
  • apps/web/components/task/task-session-sidebar.tsx
  • apps/web/components/task/task-switcher-subtask-dnd.tsx
  • apps/web/components/task/task-switcher.tsx
  • apps/web/hooks/domains/sidebar/use-sidebar-task-prefs.ts
  • apps/web/lib/local-storage.ts
  • apps/web/lib/sidebar/apply-view.test.ts
  • apps/web/lib/sidebar/apply-view.ts
  • apps/web/lib/state/slices/ui/sidebar-task-prefs-actions.ts
  • apps/web/lib/state/slices/ui/types.ts
  • apps/web/lib/state/slices/ui/ui-slice.test.ts
  • apps/web/lib/state/slices/ui/ui-slice.ts
  • apps/web/lib/state/store.ts

Comment thread apps/web/components/task/task-switcher-subtask-dnd.tsx
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.
@claude
Copy link
Copy Markdown

claude Bot commented May 20, 2026

Claude finished @jcfs's task in 2m 26s —— View job


Code Review

  • Gather context
  • Read review skill
  • Inspect changed files
  • Post findings

Findings

All three suggestions from the initial review and the CodeRabbit key-stability fix have landed. One minor item remains:

Suggestion (recommended, doesn't block)

  1. Duplicated DRAG_ACTIVATION_DISTANCE constanttask-switcher.tsx:28 and task-switcher-subtask-dnd.tsx:22
    • Issue: Both files define const DRAG_ACTIVATION_DISTANCE = 8. If the activation distance ever changes (e.g., to tune for touch), two places need updating.
    • Fix: Export from task-switcher-subtask-dnd.tsx (which owns the subtask sensor) and import in task-switcher.tsx — or move it to a shared dnd-constants.ts sibling. Either way is a one-line change.

Summary

Severity Count
Blocker 0
Suggestion 1

Verdict: Ready to merge. The one suggestion is a trivial constant dedup that can follow as cleanup.

Comment thread apps/web/components/task/task-switcher-subtask-dnd.tsx Outdated
Export the constant from `task-switcher-subtask-dnd.tsx` and import it
in `task-switcher.tsx` instead of declaring the same value twice.
@claude
Copy link
Copy Markdown

claude Bot commented May 21, 2026

Claude finished @jcfs's task in 1m 46s —— View job


Code Review

  • Gather context
  • Read review skill
  • Inspect changed files
  • Post findings

Findings

The latest commit (b710df3) addresses the last outstanding suggestion — exporting DRAG_ACTIVATION_DISTANCE from task-switcher-subtask-dnd.tsx and importing it in task-switcher.tsx instead of declaring the same value twice. The change is minimal and correct.

No new issues found. All four review suggestions across the three previous review rounds have now been resolved:

Suggestion Commit
Extract shared pruneSubtaskOrder helper (duplicate cleanup loop) ebbba6a
Guard SortableSubtaskList against undefined onReorderSubtasks ebbba6a
Extract DnD components to sibling file (file length) ebbba6a
Add stable Fragment key to non-DnD render path af94d83
Single-source DRAG_ACTIVATION_DISTANCE constant b710df3

task-switcher.tsx is 518 lines, well under the 600-line limit.

Summary

Severity Count
Blocker 0
Suggestion 0

Verdict: Ready to merge.

@jcfs jcfs merged commit 35242b2 into main May 21, 2026
42 checks passed
@jcfs jcfs deleted the feature/order-subtasks-insid-bg4 branch May 21, 2026 00:31
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant