35242b2982
* 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>
166 lines
5.3 KiB
TypeScript
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>
|
|
);
|
|
});
|