Files
kandev/apps/web/components/task/mobile/session-task-switcher-sheet.tsx
T
João Salavisa 35242b2982 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>
2026-05-21 01:31:42 +01:00

166 lines
5.3 KiB
TypeScript

"use client";
import { useMemo, useState, memo } from "react";
import { IconPlus } from "@tabler/icons-react";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Button } from "@kandev/ui/button";
import { TaskSwitcher } from "../task-switcher";
import type { TaskSwitcherItem } from "../task-switcher";
import { applyView } from "@/lib/sidebar/apply-view";
import { useEffectiveSidebarView } from "@/hooks/domains/sidebar/use-effective-sidebar-view";
import { useSidebarTaskPrefs } from "@/hooks/domains/sidebar/use-sidebar-task-prefs";
import { WorkspaceSwitcher } from "../workspace-switcher";
import { TaskCreateDialog } from "@/components/task-create-dialog";
import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog";
import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog";
import { useSheetData, useSheetActions } from "./session-task-switcher-sheet-hooks";
type SessionTaskSwitcherSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
workspaceId: string | null;
workflowId: string | null;
};
function MobileTaskList({
tasks,
activeTaskId,
selectedTaskId,
onSelectTask,
onArchiveTask,
onDeleteTask,
deletingTaskId,
isLoading,
}: {
tasks: TaskSwitcherItem[];
activeTaskId: string | null;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onArchiveTask: (taskId: string) => void;
onDeleteTask: (taskId: string) => Promise<void> | void;
deletingTaskId: string | null;
isLoading?: boolean;
}) {
const view = useEffectiveSidebarView();
const {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
togglePinnedTask,
handleReorderGroup,
handleReorderSubtasks,
} = useSidebarTaskPrefs();
const grouped = useMemo(
() =>
applyView(tasks, view, {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
}),
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId],
);
return (
<TaskSwitcher
grouped={grouped}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
onReorderSubtasks={handleReorderSubtasks}
pinnedTaskIds={pinnedTaskIds}
deletingTaskId={deletingTaskId}
isLoading={isLoading}
totalTaskCount={tasks.length}
/>
);
}
export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({
open,
onOpenChange,
workspaceId,
workflowId,
}: SessionTaskSwitcherSheetProps) {
const [dialogOpen, setDialogOpen] = useState(false);
const data = useSheetData(workspaceId, workflowId);
const actions = useSheetActions(workspaceId, onOpenChange);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
showCloseButton={false}
side="left"
className="w-[85vw] max-w-sm p-0 flex flex-col"
>
<SheetHeader className="p-4 pb-2 border-b border-border">
<div className="flex items-center justify-between">
<SheetTitle className="text-base">Tasks</SheetTitle>
<Button
size="sm"
variant="outline"
className="h-7 gap-1 cursor-pointer"
onClick={() => setDialogOpen(true)}
>
<IconPlus className="h-4 w-4" />
New
</Button>
</div>
<div className="pt-2">
<WorkspaceSwitcher
workspaces={data.workspaces.map((w) => ({ id: w.id, name: w.name }))}
activeWorkspaceId={workspaceId}
onSelect={actions.handleWorkspaceChange}
/>
</div>
</SheetHeader>
<div className="flex-1 min-h-0 overflow-y-auto p-2">
<MobileTaskList
tasks={data.tasksWithRepositories}
activeTaskId={data.activeTaskId}
selectedTaskId={data.selectedTaskId}
onSelectTask={actions.handleSelectTask}
onArchiveTask={actions.handleArchiveTask}
onDeleteTask={actions.handleDeleteTask}
deletingTaskId={actions.deletingTaskId}
isLoading={data.tasksLoading}
/>
</div>
</SheetContent>
<TaskCreateDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
mode="create"
workspaceId={workspaceId}
workflowId={workflowId}
defaultStepId={data.dialogSteps[0]?.id ?? null}
steps={data.dialogSteps}
onSuccess={actions.handleTaskCreated}
/>
<TaskArchiveConfirmDialog
open={actions.archivingTask !== null}
onOpenChange={(open) => {
if (!open) actions.setArchivingTask(null);
}}
taskTitle={actions.archivingTask?.title ?? ""}
isArchiving={actions.isArchiving}
onConfirm={actions.handleArchiveConfirm}
/>
<TaskDeleteConfirmDialog
open={actions.deletingTask !== null}
onOpenChange={(open) => {
if (!open) actions.setDeletingTask(null);
}}
taskTitle={actions.deletingTask?.title ?? ""}
isDeleting={actions.isDeleting}
onConfirm={actions.handleDeleteConfirm}
/>
</Sheet>
);
});