feat: re-parent tasks by drag and drop in the sidebar (#2355)

This commit is contained in:
Clément Fiere
2026-08-07 16:33:41 +02:00
committed by GitHub
parent 10cd0c0a84
commit 4d80edacf2
29 changed files with 1721 additions and 115 deletions
@@ -135,6 +135,7 @@ func newTestDeps(t *testing.T) *testDeps {
project_id TEXT DEFAULT '',
assignee_agent_profile_id TEXT DEFAULT '',
labels TEXT DEFAULT '[]',
metadata TEXT DEFAULT '{}',
identifier TEXT DEFAULT '',
is_ephemeral INTEGER DEFAULT 0,
origin TEXT DEFAULT 'manual',
@@ -2,6 +2,7 @@ package dashboard_test
import (
"context"
"encoding/json"
"testing"
taskmodels "github.com/kandev/kandev/internal/task/models"
@@ -65,6 +66,7 @@ func TestUpdateTaskParentIDFailsClosedWhenDetacherIsNotConfigured(t *testing.T)
func TestUpdateTaskParentIDKeepsNonEmptyReparentingInOffice(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
insertTestTask(t, deps.db, "new-parent", "workspace", "New parent", "todo", 1)
detacher := &recordingTaskDetacher{}
deps.svc.SetTaskDetacher(detacher)
@@ -83,3 +85,101 @@ func TestUpdateTaskParentIDKeepsNonEmptyReparentingInOffice(t *testing.T) {
t.Fatalf("parent_id = %q, want new-parent", parentID)
}
}
// The Office non-empty reparent path must apply the same composite workspace
// semantics as the canonical detach: an inherit_parent subtask re-parented to
// a new parent keeps its materialized workspace as shared_group instead of
// silently inheriting the new parent's.
func TestUpdateTaskParentIDNormalizesInheritedWorkspaceMode(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
insertTestTask(t, deps.db, "new-parent", "workspace", "New parent", "todo", 1)
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent', metadata = '{"workspace":{"mode":"inherit_parent","group_id":"group-1"}}' WHERE id = 'child'`); err != nil {
t.Fatalf("set parent/metadata: %v", err)
}
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "new-parent"); err != nil {
t.Fatalf("UpdateTaskParentID: %v", err)
}
var parentID string
if err := deps.db.Get(&parentID, `SELECT parent_id FROM tasks WHERE id = 'child'`); err != nil {
t.Fatalf("select parent: %v", err)
}
if parentID != "new-parent" {
t.Fatalf("parent_id = %q, want new-parent", parentID)
}
var metadata string
if err := deps.db.Get(&metadata, `SELECT metadata FROM tasks WHERE id = 'child'`); err != nil {
t.Fatalf("select metadata: %v", err)
}
var parsed struct {
Workspace struct {
Mode string `json:"mode"`
GroupID string `json:"group_id"`
} `json:"workspace"`
}
if err := json.Unmarshal([]byte(metadata), &parsed); err != nil {
t.Fatalf("parse metadata %q: %v", metadata, err)
}
if parsed.Workspace.Mode != "shared_group" {
t.Fatalf("workspace mode = %q, want shared_group", parsed.Workspace.Mode)
}
if parsed.Workspace.GroupID != "group-1" {
t.Fatalf("workspace group_id = %q, want group-1", parsed.Workspace.GroupID)
}
}
// A repeated PATCH with the same parent_id must not flip workspace semantics:
// the task was not re-parented, so an inherit_parent mode is preserved.
func TestUpdateTaskParentIDSameParentDoesNotNormalizeMetadata(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
insertTestTask(t, deps.db, "parent", "workspace", "Parent", "todo", 1)
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent', metadata = '{"workspace":{"mode":"inherit_parent","group_id":"group-1"}}' WHERE id = 'child'`); err != nil {
t.Fatalf("set parent/metadata: %v", err)
}
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "parent"); err != nil {
t.Fatalf("UpdateTaskParentID: %v", err)
}
var metadata string
if err := deps.db.Get(&metadata, `SELECT metadata FROM tasks WHERE id = 'child'`); err != nil {
t.Fatalf("select metadata: %v", err)
}
var parsed struct {
Workspace struct {
Mode string `json:"mode"`
} `json:"workspace"`
}
if err := json.Unmarshal([]byte(metadata), &parsed); err != nil {
t.Fatalf("parse metadata %q: %v", metadata, err)
}
if parsed.Workspace.Mode != "inherit_parent" {
t.Fatalf("workspace mode = %q, want inherit_parent (same-parent update must not normalize)", parsed.Workspace.Mode)
}
}
// A non-empty parent_id that does not exist must be rejected instead of
// silently writing a dangling parent reference.
func TestUpdateTaskParentIDRejectsNonExistentParent(t *testing.T) {
deps := newTestDeps(t)
insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1)
if _, err := deps.db.Exec(`UPDATE tasks SET parent_id = 'parent' WHERE id = 'child'`); err != nil {
t.Fatalf("set parent: %v", err)
}
if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "missing-parent"); err == nil {
t.Fatal("UpdateTaskParentID error = nil, want missing-parent error")
}
var parentID string
if err := deps.db.Get(&parentID, `SELECT parent_id FROM tasks WHERE id = 'child'`); err != nil {
t.Fatalf("select parent: %v", err)
}
if parentID != "parent" {
t.Fatalf("parent_id = %q, want unchanged 'parent'", parentID)
}
}
@@ -65,8 +65,12 @@ func (s *DashboardService) UpdateTaskProjectID(ctx context.Context, taskID, proj
}
// UpdateTaskParentID sets the parent_id field. Empty string clears the
// parent. Rejects only direct self-reference (parentID == taskID); deeper
// cycle detection is intentionally not enforced.
// parent (via the canonical detacher). A non-empty value must reference an
// existing task and also applies the canonical re-parent workspace policy:
// an inherit_parent subtask keeps its materialized workspace as shared_group
// instead of inheriting the new parent's. Rejects only direct self-reference
// (parentID == taskID); deeper cycle detection is intentionally not enforced
// on this surface.
func (s *DashboardService) UpdateTaskParentID(ctx context.Context, taskID, parentID string) error {
if parentID != "" && parentID == taskID {
return fmt.Errorf("task cannot be its own parent")
@@ -80,10 +84,20 @@ func (s *DashboardService) UpdateTaskParentID(ctx context.Context, taskID, paren
}
return nil
}
// Preflight existence so a concurrent parent deletion (or a direct hit on
// the endpoint) cannot write a dangling parent_id — mirroring the
// canonical resolveParentID guard.
parent, err := s.repo.GetTaskByID(ctx, parentID)
if err != nil {
return fmt.Errorf("resolve parent task: %w", err)
}
if parent == nil {
return fmt.Errorf("parent task not found: %s", parentID)
}
if err := s.repo.UpdateTaskParentID(ctx, taskID, parentID); err != nil {
return err
}
s.publishTaskUpdated(ctx, taskID, []string{"parent_id"})
s.publishTaskUpdated(ctx, taskID, []string{"parent_id", "metadata"})
return nil
}
@@ -760,9 +760,37 @@ func (r *Repository) UpdateTaskProjectID(ctx context.Context, taskID, projectID
return r.execTaskScalar(ctx, taskID, "project_id", projectID)
}
// UpdateTaskParentID sets the parent_id column. Empty string clears it.
// UpdateTaskParentID sets the parent_id column and applies the canonical
// re-parent workspace policy: an inherit_parent subtask whose parent is
// changing keeps its materialized workspace as shared_group instead of
// silently inheriting the new parent's. Empty string clears the parent.
// The metadata normalization is conditioned on the parent actually changing,
// so a repeated PATCH with the same parent_id is a no-op for workspace
// semantics.
func (r *Repository) UpdateTaskParentID(ctx context.Context, taskID, parentID string) error {
return r.execTaskScalar(ctx, taskID, "parent_id", parentID)
query := `
UPDATE tasks
SET parent_id = ?,
metadata = CASE
WHEN parent_id IS NOT ? AND json_valid(metadata) THEN CASE
WHEN json_extract(metadata, '$.workspace.mode') = 'inherit_parent'
THEN json_set(metadata, '$.workspace.mode', 'shared_group')
ELSE metadata
END
ELSE metadata
END,
updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`
result, err := r.db.ExecContext(ctx, r.db.Rebind(query), parentID, parentID, taskID)
if err != nil {
return err
}
rows, _ := result.RowsAffected()
if rows == 0 {
return fmt.Errorf("task not found: %s", taskID)
}
return nil
}
// taskScalarColumns enumerates the only columns execTaskScalar may target.
@@ -772,7 +800,6 @@ func (r *Repository) UpdateTaskParentID(ctx context.Context, taskID, parentID st
var taskScalarColumns = map[string]struct{}{
"priority": {},
"project_id": {},
"parent_id": {},
}
// execTaskScalar updates a single TEXT column on a task and bumps updated_at.
@@ -324,3 +324,132 @@ func TestService_UpdateTask_RejectsCrossWorkspaceParent(t *testing.T) {
t.Fatalf("expected cross-workspace error, got %v", err)
}
}
// setInheritedWorkspaceMode stamps an inherit_parent workspace block (with a
// group id) onto an existing task row, simulating a subtask created under a
// parent with inherited workspace policy.
func setInheritedWorkspaceMode(t *testing.T, ctx context.Context, repo *sqliterepo.Repository, taskID string) {
t.Helper()
task, err := repo.GetTask(ctx, taskID)
if err != nil {
t.Fatalf("GetTask %s: %v", taskID, err)
}
task.Metadata["workspace"] = map[string]interface{}{
"mode": "inherit_parent",
"group_id": "group-1",
}
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask fixture %s: %v", taskID, err)
}
}
func assertWorkspaceMode(t *testing.T, metadata map[string]interface{}, want string) {
t.Helper()
workspace, ok := metadata["workspace"].(map[string]interface{})
if !ok {
t.Fatalf("workspace metadata = %#v", metadata["workspace"])
}
if workspace["mode"] != want {
t.Fatalf("workspace mode = %#v, want %s", workspace["mode"], want)
}
}
// Reparenting an inherit_parent subtask must normalize the mode to
// shared_group — the same composite result as detach-then-nest — while
// keeping its workspace group id.
func TestService_UpdateTask_ReparentNormalizesInheritedWorkspaceMode(t *testing.T) {
svc, eventBus, repo, create := reparentFixture(t)
ctx := context.Background()
oldParent := create("Old parent")
child := create("Child")
newParent := create("New parent")
if _, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(oldParent.ID)}); err != nil {
t.Fatalf("nest child under old parent: %v", err)
}
setInheritedWorkspaceMode(t, ctx, repo, child.ID)
eventBus.ClearEvents()
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(newParent.ID)})
if err != nil {
t.Fatalf("reparent: %v", err)
}
if updated.ParentID != newParent.ID {
t.Fatalf("ParentID = %q, want %q", updated.ParentID, newParent.ID)
}
assertWorkspaceMode(t, updated.Metadata, "shared_group")
persisted, err := repo.GetTask(ctx, child.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
assertWorkspaceMode(t, persisted.Metadata, "shared_group")
workspace := persisted.Metadata["workspace"].(map[string]interface{})
if workspace["group_id"] != "group-1" {
t.Fatalf("workspace group_id = %#v, want group-1", workspace["group_id"])
}
eventData := singleDetachmentEventData(t, eventBus)
if eventData["parent_id"] != newParent.ID {
t.Fatalf("event parent_id = %#v, want %q", eventData["parent_id"], newParent.ID)
}
eventMetadata, ok := eventData["metadata"].(map[string]interface{})
if !ok {
t.Fatalf("event metadata = %#v", eventData["metadata"])
}
assertWorkspaceMode(t, eventMetadata, "shared_group")
}
// Un-nesting through UpdateTask (empty parent_id) must apply the same
// normalization as the detach endpoint.
func TestService_UpdateTask_UnnestNormalizesInheritedWorkspaceMode(t *testing.T) {
svc, eventBus, repo, create := reparentFixture(t)
ctx := context.Background()
parent := create("Parent")
child := create("Child")
if _, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(parent.ID)}); err != nil {
t.Fatalf("nest: %v", err)
}
setInheritedWorkspaceMode(t, ctx, repo, child.ID)
eventBus.ClearEvents()
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr("")})
if err != nil {
t.Fatalf("unnest: %v", err)
}
assertWorkspaceMode(t, updated.Metadata, "shared_group")
persisted, err := repo.GetTask(ctx, child.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
assertWorkspaceMode(t, persisted.Metadata, "shared_group")
}
// Non-inherited workspace modes must survive a re-parent untouched.
func TestService_UpdateTask_ReparentPreservesNonInheritedWorkspaceModes(t *testing.T) {
for _, mode := range []string{"shared_group", "new_workspace"} {
t.Run(mode, func(t *testing.T) {
svc, _, repo, create := reparentFixture(t)
ctx := context.Background()
child := create("Child")
newParent := create("New parent")
task, err := repo.GetTask(ctx, child.ID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
task.Metadata["workspace"] = map[string]interface{}{
"mode": mode,
"group_id": "group-1",
}
if err := repo.UpdateTask(ctx, task); err != nil {
t.Fatalf("UpdateTask fixture: %v", err)
}
updated, err := svc.UpdateTask(ctx, child.ID, &UpdateTaskRequest{ParentID: strptr(newParent.ID)})
if err != nil {
t.Fatalf("reparent: %v", err)
}
assertWorkspaceMode(t, updated.Metadata, mode)
})
}
}
@@ -1256,6 +1256,11 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
}
parentCleared = *req.ParentID == ""
task.ParentID = *req.ParentID
// Re-parenting (or un-nesting) an inherit_parent subtask keeps its
// materialized workspace as shared_group instead of silently
// inheriting a different parent's workspace — the same composite
// semantics as detach-then-nest.
normalizeWorkspaceModeAfterReparent(task)
}
task.UpdatedAt = time.Now().UTC()
@@ -1387,6 +1392,22 @@ const parentIDEventField = "parent_id"
// rather than an internal error (500).
var ErrInvalidParent = errors.New("invalid parent")
// normalizeWorkspaceModeAfterReparent applies the detach operation's
// workspace policy to an explicit parent change: an inherit_parent subtask
// whose hierarchy is being changed must not silently start inheriting a
// different parent's workspace, so its mode becomes shared_group (its
// materialized workspace and group membership are unchanged). Other modes
// pass through.
func normalizeWorkspaceModeAfterReparent(task *models.Task) {
workspace, ok := task.Metadata["workspace"].(map[string]interface{})
if !ok {
return
}
if workspace["mode"] == workspaceModeInheritParent {
workspace["mode"] = workspaceModeSharedGroup
}
}
// resolveParentID validates a proposed parent assignment for task. An empty
// parentID (un-nest) is always allowed. A non-empty parentID must reference a
// different, existing, non-archived task in the same workspace, and must not
@@ -9,6 +9,7 @@ import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
import { useNestTaskByDrag } from "@/hooks/use-nest-task";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { workspaceModeFromMetadata } from "@/lib/kanban/map-task";
import {
@@ -523,6 +524,14 @@ function useSheetDeleteActions(
};
}
/**
* Re-parent via drag: runs the same composite nest operation the context menu
* uses, resolving the workflow from the snapshot keys.
*/
function useSheetNestTask() {
return useNestTaskByDrag();
}
export function useSheetActions(workspaceId: string | null, onOpenChange: (open: boolean) => void) {
const { t } = useTranslation();
const setActiveTask = useAppStore((state) => state.setActiveTask);
@@ -532,6 +541,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
const detachActions = useTaskDetachDialog(store);
const handleNestTask = useSheetNestTask();
const handleSelectTask = useCallback(
(taskId: string) => {
@@ -617,6 +627,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
handleArchiveTask,
handleWorkspaceChange,
handleTaskCreated,
handleNestTask,
archivingTask,
setArchivingTask,
isArchiving,
@@ -7,7 +7,7 @@ import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@kandev/ui/sheet";
import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
import { Button } from "@kandev/ui/button";
import { TaskSwitcher } from "../task-switcher";
import type { TaskSwitcherItem } from "../task-switcher";
import type { TaskSwitcherItem, TaskSwitcherProps } from "../task-switcher";
import { SidebarFilterBar } from "../sidebar-filter/sidebar-filter-bar";
import type { StepDef } from "../task-switcher-context-menu";
import type { TaskMoveWorkflow } from "../task-move-context-menu";
@@ -60,32 +60,7 @@ function useSidebarGroupToggle(viewId: string) {
);
}
// eslint-disable-next-line max-lines-per-function -- assembles the shared task tree with the mobile drawer's view state.
export function MobileTaskList({
tasks,
workflows,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
onSelectTask,
onEditTask,
onRenameTask,
onCreateSubtask,
onArchiveTask,
onDeleteTask,
onDetachTask,
onLinkPullRequest,
onLinkIssue,
onLinkMergeRequest,
onLinkJiraTicket,
onLinkLinearIssue,
onLinkSentryIssue,
deletingTaskId,
isLoading,
loadError,
onRetryLoad,
retryLabel,
}: {
type MobileTaskListProps = {
tasks: TaskSwitcherItem[];
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, StepDef[]>;
@@ -98,6 +73,7 @@ export function MobileTaskList({
onArchiveTask: (taskId: string) => void;
onDeleteTask: (taskId: string) => Promise<void> | void;
onDetachTask: (taskId: string) => Promise<void> | void;
onNestTask?: (taskId: string, parentTaskId: string) => void;
onLinkPullRequest?: (taskId: string, taskTitle?: string) => void;
onLinkIssue?: (taskId: string, taskTitle?: string) => void;
onLinkMergeRequest?: (taskId: string, taskTitle?: string) => void;
@@ -109,7 +85,69 @@ export function MobileTaskList({
loadError?: string | null;
onRetryLoad?: () => void;
retryLabel?: string;
}) {
};
/**
* Assembles the TaskSwitcher props for the mobile task list, mirroring the
* desktop `buildTaskSwitcherProps` so the prop-forwarding surface stays a
* thin mapping layer.
*/
function buildMobileTaskSwitcherProps(
props: MobileTaskListProps,
helpers: {
grouped: TaskSwitcherProps["grouped"];
collapsedGroupKeys: string[];
onToggleGroup: (groupKey: string) => void;
collapsedSubtaskParentIds: string[];
onToggleSubtasks: (parentTaskId: string) => void;
onTogglePin: (taskId: string) => void;
onReorderGroup: (groupTaskIds: string[]) => void;
onReorderSubtasks: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
pinnedTaskIds: string[];
},
): TaskSwitcherProps {
return {
grouped: helpers.grouped,
workflows: props.workflows,
stepsByWorkflowId: props.stepsByWorkflowId,
activeTaskId: props.activeTaskId,
selectedTaskId: props.selectedTaskId,
collapsedGroupKeys: helpers.collapsedGroupKeys,
onToggleGroup: helpers.onToggleGroup,
collapsedSubtaskParentIds: helpers.collapsedSubtaskParentIds,
onToggleSubtasks: helpers.onToggleSubtasks,
onSelectTask: props.onSelectTask,
onEditTask: props.onEditTask,
onRenameTask: props.onRenameTask,
onCreateSubtask: props.onCreateSubtask,
onArchiveTask: props.onArchiveTask,
onDeleteTask: props.onDeleteTask,
onDetachTask: props.onDetachTask,
onNestTask: props.onNestTask,
onLinkPullRequest: props.onLinkPullRequest,
onLinkIssue: props.onLinkIssue,
onLinkMergeRequest: props.onLinkMergeRequest,
onLinkJiraTicket: props.onLinkJiraTicket,
onLinkLinearIssue: props.onLinkLinearIssue,
onLinkSentryIssue: props.onLinkSentryIssue,
onTogglePin: helpers.onTogglePin,
onReorderGroup: helpers.onReorderGroup,
onReorderSubtasks: helpers.onReorderSubtasks,
pinnedTaskIds: helpers.pinnedTaskIds,
deletingTaskId: props.deletingTaskId,
isLoading: props.isLoading,
loadError: props.loadError,
onRetryLoad: props.onRetryLoad,
retryLabel: props.retryLabel,
totalTaskCount: props.tasks.length,
};
}
/**
* The mobile task tree surface: renders the shared TaskSwitcher with the
* mobile drawer's view state (grouping, ordering, collapse, reorder, nest).
*/
export function MobileTaskList(props: MobileTaskListProps) {
const view = useEffectiveSidebarView();
const {
pinnedTaskIds,
@@ -126,49 +164,25 @@ export function MobileTaskList({
const { i18n } = useTranslation();
const grouped = useMemo(
() =>
applyView(tasks, view, {
applyView(props.tasks, view, {
pinnedTaskIds,
orderedTaskIds,
subtaskOrderByParentId,
}),
[tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId, i18n.language],
);
return (
<TaskSwitcher
grouped={grouped}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
collapsedGroupKeys={view.collapsedGroups}
onToggleGroup={handleToggleGroup}
collapsedSubtaskParentIds={collapsedSubtaskParents}
onToggleSubtasks={toggleSubtaskCollapsed}
onSelectTask={onSelectTask}
onEditTask={onEditTask}
onRenameTask={onRenameTask}
onCreateSubtask={onCreateSubtask}
onArchiveTask={onArchiveTask}
onDeleteTask={onDeleteTask}
onDetachTask={onDetachTask}
onLinkPullRequest={onLinkPullRequest}
onLinkIssue={onLinkIssue}
onLinkMergeRequest={onLinkMergeRequest}
onLinkJiraTicket={onLinkJiraTicket}
onLinkLinearIssue={onLinkLinearIssue}
onLinkSentryIssue={onLinkSentryIssue}
onTogglePin={togglePinnedTask}
onReorderGroup={handleReorderGroup}
onReorderSubtasks={handleReorderSubtasks}
pinnedTaskIds={pinnedTaskIds}
deletingTaskId={deletingTaskId}
isLoading={isLoading}
loadError={loadError}
onRetryLoad={onRetryLoad}
retryLabel={retryLabel}
totalTaskCount={tasks.length}
/>
[props.tasks, view, pinnedTaskIds, orderedTaskIds, subtaskOrderByParentId, i18n.language],
);
const switcherProps = buildMobileTaskSwitcherProps(props, {
grouped,
collapsedGroupKeys: view.collapsedGroups,
onToggleGroup: handleToggleGroup,
collapsedSubtaskParentIds: collapsedSubtaskParents,
onToggleSubtasks: toggleSubtaskCollapsed,
onTogglePin: togglePinnedTask,
onReorderGroup: handleReorderGroup,
onReorderSubtasks: handleReorderSubtasks,
pinnedTaskIds,
});
return <TaskSwitcher {...switcherProps} />;
}
function TaskSwitcherSurfaceHeader({
@@ -334,6 +348,7 @@ function TaskSwitcherSurfaceContent({
onArchiveTask={surfaceAction(presentation, onOpenChange, actions.handleArchiveTask)}
onDeleteTask={surfaceAction(presentation, onOpenChange, actions.handleDeleteTask)}
onDetachTask={surfaceAction(presentation, onOpenChange, actions.handleDetachTask)}
onNestTask={actions.handleNestTask}
onLinkPullRequest={surfaceAction(
presentation,
onOpenChange,
@@ -27,6 +27,7 @@ export function buildTaskSwitcherProps(args: {
togglePinnedTask: TaskSwitcherComponentProps["onTogglePin"];
handleReorderGroup: TaskSwitcherComponentProps["onReorderGroup"];
handleReorderSubtasks: TaskSwitcherComponentProps["onReorderSubtasks"];
handleNestTask: TaskSwitcherComponentProps["onNestTask"];
isLoadingWorkflow: boolean;
archivedError: string | null;
retryArchivedTasks: () => void;
@@ -57,6 +58,7 @@ export function buildTaskSwitcherProps(args: {
onTogglePin: args.togglePinnedTask,
onReorderGroup: args.handleReorderGroup,
onReorderSubtasks: args.handleReorderSubtasks,
onNestTask: args.handleNestTask,
pinnedTaskIds: args.pinnedTaskIds,
deletingTaskId: args.sidebarActions.deletingTaskId,
isLoading: args.isLoadingWorkflow,
@@ -17,6 +17,7 @@ import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
import { useNestTaskByDrag } from "@/hooks/use-nest-task";
import { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { findTaskInSnapshots } from "@/lib/kanban/find-task";
@@ -342,6 +343,7 @@ export function useSidebarActions(store: StoreApi) {
const archiveActions = useArchiveActions(store);
const deleteActions = useDeleteActions(store, removeTaskFromBoard);
const detachActions = useTaskDetachDialog(store);
const handleNestTask = useNestTaskByDrag();
const linkActions = useSidebarLinkActions(store);
const editActions = useSidebarTaskEdit();
@@ -377,6 +379,7 @@ export function useSidebarActions(store: StoreApi) {
preparingTaskId,
handleSelectTask,
handleMoveToStep,
handleNestTask,
renamingTask,
setRenamingTask,
handleRenameTask,
@@ -467,6 +470,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
togglePinnedTask,
handleReorderGroup,
handleReorderSubtasks,
handleNestTask: sidebarActions.handleNestTask,
isLoadingWorkflow,
archivedError,
retryArchivedTasks,
@@ -1,24 +1,157 @@
import { describe, expect, it } from "vitest";
import {
DRAG_ACTIVATION_DISTANCE,
TOUCH_DRAG_ACTIVATION_DELAY_MS,
TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
taskSwitcherDragActivationConstraints,
} from "@/components/task/task-switcher-subtask-dnd";
computeNestTargets,
nestDroppableId,
parseNestDroppableId,
resolveSidebarDrop,
} from "./task-switcher-subtask-dnd";
describe("task switcher subtask DnD activation", () => {
it("keeps pointer dragging responsive but delays touch dragging for scrollable mobile lists", () => {
expect(taskSwitcherDragActivationConstraints()).toEqual({
pointer: { distance: DRAG_ACTIVATION_DISTANCE },
touch: {
delay: TOUCH_DRAG_ACTIVATION_DELAY_MS,
tolerance: TOUCH_DRAG_ACTIVATION_TOLERANCE_PX,
},
});
// Fixture ids hoisted so the repeated literals stay below sonarjs's
// no-duplicate-string threshold.
const ROOT_A = "a";
const ROOT_B = "b";
const SUB_C = "c";
const SUB_D = "d";
const SUB_E = "e";
const WORKFLOW = "wf-1";
const OTHER_WORKFLOW = "wf-2";
const PWC = "parent-with-child"; // root that already has a child
const PWC_CHILD = "child-of-pwc";
function task(id: string, overrides: { workflowId?: string; parentTaskId?: string | null } = {}) {
return {
id,
title: id,
workflowId: overrides.workflowId ?? WORKFLOW,
parentTaskId: overrides.parentTaskId ?? undefined,
};
}
describe("nestDroppableId / parseNestDroppableId", () => {
it("round-trips task ids through the nest prefix", () => {
expect(parseNestDroppableId(nestDroppableId("task-1"))).toBe("task-1");
});
it("reuses stable activation constraint references across renders", () => {
expect(taskSwitcherDragActivationConstraints()).toBe(taskSwitcherDragActivationConstraints());
it("returns null for non-nest droppable ids", () => {
expect(parseNestDroppableId("task-1")).toBeNull();
});
});
describe("resolveSidebarDrop", () => {
const roots = [ROOT_A, ROOT_B];
const childrenByParent = new Map<string, string[]>([
[ROOT_A, [SUB_C, SUB_D]],
[ROOT_B, [SUB_E]],
]);
it("maps a drop on a nest zone to a re-parent", () => {
expect(
resolveSidebarDrop({
activeId: SUB_C,
overId: nestDroppableId(ROOT_B),
groupRootIds: roots,
childrenByParent,
}),
).toEqual({ kind: "nest", parentTaskId: ROOT_B });
});
it("rejects nesting under the dragged task itself", () => {
expect(
resolveSidebarDrop({
activeId: ROOT_A,
overId: nestDroppableId(ROOT_A),
groupRootIds: roots,
childrenByParent,
}),
).toBeNull();
});
it("reorders within the group root level", () => {
expect(
resolveSidebarDrop({
activeId: ROOT_A,
overId: ROOT_B,
groupRootIds: roots,
childrenByParent,
}),
).toEqual({ kind: "reorder-group", orderedTaskIds: [ROOT_B, ROOT_A] });
});
it("reorders within one parent's children", () => {
expect(
resolveSidebarDrop({
activeId: SUB_C,
overId: SUB_D,
groupRootIds: roots,
childrenByParent,
}),
).toEqual({ kind: "reorder-subtasks", parentTaskId: ROOT_A, orderedTaskIds: [SUB_D, SUB_C] });
});
it("rejects cross-level drops (subtask onto a sibling root)", () => {
expect(
resolveSidebarDrop({
activeId: SUB_C,
overId: ROOT_B,
groupRootIds: roots,
childrenByParent,
}),
).toBeNull();
});
it("rejects a drop on the dragged task's own row", () => {
expect(
resolveSidebarDrop({
activeId: SUB_C,
overId: SUB_C,
groupRootIds: roots,
childrenByParent,
}),
).toBeNull();
});
it("rejects a drop with no over target", () => {
expect(
resolveSidebarDrop({
activeId: SUB_C,
overId: null,
groupRootIds: roots,
childrenByParent,
}),
).toBeNull();
});
});
describe("computeNestTargets", () => {
const tasks = [
task(ROOT_A), // root
task(SUB_C, { parentTaskId: ROOT_A }), // subtask of a
task(ROOT_B), // root
task(SUB_E, { parentTaskId: ROOT_B }), // subtask of b
task("other-wf", { workflowId: OTHER_WORKFLOW }), // root in another workflow
task(PWC), // root with a child (valid target)
task(PWC_CHILD, { parentTaskId: PWC }),
];
it("offers same-workflow roots excluding the task and its current parent", () => {
// c (subtask of a) may nest under b and parent-with-child only.
expect(computeNestTargets(task(SUB_C, { parentTaskId: ROOT_A }), tasks)).toEqual(
new Set([ROOT_B, PWC]),
);
});
it("offers no targets when the task has children (would exceed one level)", () => {
expect(computeNestTargets(task(PWC), tasks)).toEqual(new Set());
});
it("offers no targets when the active task is unknown", () => {
expect(computeNestTargets(undefined, tasks)).toEqual(new Set());
});
it("excludes subtasks and other workflows even when they are roots", () => {
const targets = computeNestTargets(task(SUB_C, { parentTaskId: ROOT_A }), tasks);
expect(targets.has(SUB_E)).toBe(false);
expect(targets.has("other-wf")).toBe(false);
expect(targets.has(SUB_C)).toBe(false);
});
});
@@ -1,12 +1,16 @@
"use client";
import { useCallback, useMemo } from "react";
import { useCallback, useMemo, useState } from "react";
import {
DndContext,
MouseSensor,
TouchSensor,
closestCenter,
pointerWithin,
useDroppable,
type CollisionDetection,
type DragEndEvent,
type DragStartEvent,
useSensor,
useSensors,
} from "@dnd-kit/core";
@@ -17,7 +21,9 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
import { computeNestCandidates } from "@/lib/sidebar/nest-candidates";
import type { TaskSwitcherItem } from "./task-switcher";
export const DRAG_ACTIVATION_DISTANCE = 8;
@@ -32,6 +38,7 @@ const TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS = {
},
};
/** Shared mouse/touch activation constraints for the sidebar tree drags. */
export function taskSwitcherDragActivationConstraints() {
return TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS;
}
@@ -43,6 +50,120 @@ export function taskSwitcherDragActivationConstraints() {
// measurements remain accurate for previously rendered rows.
const OFFSCREEN_SKIP = "[content-visibility:auto] [contain-intrinsic-block-size:auto_52px]";
// Nest drop zones are registered as `nest:<taskId>` droppables. A drop on one
// re-parents the dragged task under that task; any other drop keeps the
// existing sibling-reorder semantics.
export const NEST_DROPPABLE_PREFIX = "nest:";
/** Droppable id of a row's nest drop zone: `nest:<taskId>`. */
export function nestDroppableId(taskId: string): string {
return NEST_DROPPABLE_PREFIX + taskId;
}
/** Returns the task id when `id` is a nest droppable id, else null. */
export function parseNestDroppableId(id: string): string | null {
return id.startsWith(NEST_DROPPABLE_PREFIX) ? id.slice(NEST_DROPPABLE_PREFIX.length) : null;
}
export type SidebarDropDecision =
| { kind: "nest"; parentTaskId: string }
| { kind: "reorder-group"; orderedTaskIds: string[] }
| { kind: "reorder-subtasks"; parentTaskId: string; orderedTaskIds: string[] };
/**
* Maps a group-level drag end onto the single intended action.
*
* A drop on a `nest:` droppable re-parents the dragged task. Any other drop
* reorders only when both tasks share a sibling level (group root list or one
* parent's children); cross-level drops are a no-op, matching the previous
* per-level behavior where a drag could never leave its own level.
*/
export function resolveSidebarDrop(args: {
activeId: string;
overId: string | null;
groupRootIds: string[];
childrenByParent: Map<string, string[]>;
}): SidebarDropDecision | null {
const { activeId, overId, groupRootIds, childrenByParent } = args;
if (!overId || overId === activeId) return null;
const nestTarget = parseNestDroppableId(overId);
if (nestTarget !== null) {
if (nestTarget === activeId) return null;
return { kind: "nest", parentTaskId: nestTarget };
}
const levelOf = (taskId: string): string | null => {
if (groupRootIds.includes(taskId)) return GROUP_ROOT_LEVEL;
for (const [parentId, ids] of childrenByParent) {
if (ids.includes(taskId)) return parentId;
}
return null;
};
const activeLevel = levelOf(activeId);
const overLevel = levelOf(overId);
if (activeLevel === null || activeLevel !== overLevel) return null;
const levelIds =
activeLevel === GROUP_ROOT_LEVEL ? groupRootIds : (childrenByParent.get(activeLevel) ?? []);
const oldIndex = levelIds.indexOf(activeId);
const newIndex = levelIds.indexOf(overId);
if (oldIndex < 0 || newIndex < 0) return null;
const ordered = arrayMove(levelIds, oldIndex, newIndex);
if (activeLevel === GROUP_ROOT_LEVEL) {
return { kind: "reorder-group", orderedTaskIds: ordered };
}
return { kind: "reorder-subtasks", parentTaskId: activeLevel, orderedTaskIds: ordered };
}
// Sentinel level id for the group root list inside resolveSidebarDrop.
const GROUP_ROOT_LEVEL = "__group_root__";
/**
* Computes the rows a drag may nest the active task under, mirroring the
* context menu's candidate rules (computeNestCandidates) scoped to the
* active task's workflow: same-workflow roots, excluding the task itself,
* its current parent, and any subtask (which also excludes its descendants).
*/
export function computeNestTargets(
activeTask: Pick<TaskSwitcherItem, "id" | "workflowId" | "parentTaskId"> | undefined,
groupTasks: TaskSwitcherItem[],
): Set<string> {
if (!activeTask) return new Set();
const sameWorkflow = groupTasks.filter((t) => t.workflowId === activeTask.workflowId);
return new Set(computeNestCandidates(sameWorkflow, activeTask.id).map((t) => t.id));
}
/**
* The drop target rendered on a row that the active drag may nest under.
* Absolute strip at the row's left edge; dropping on it re-parents the
* dragged task instead of reordering. Rendered only during an active drag
* with valid targets, so it never intercepts normal row clicks.
*/
export function NestDropZone({ taskId, title }: { taskId: string; title: string }) {
const { t } = useTranslation();
const { setNodeRef, isOver } = useDroppable({ id: nestDroppableId(taskId) });
const label = t("sidebar:nestUnder", { title });
return (
<div
ref={setNodeRef}
data-testid="nest-drop-zone"
data-task-id={taskId}
aria-label={label}
className={cn(
"absolute inset-y-0 left-0 z-20 w-6",
isOver ? "bg-primary/20" : "bg-transparent hover:bg-accent/40",
)}
>
{isOver && (
<span className="pointer-events-none absolute left-6 top-1/2 -translate-y-1/2 whitespace-nowrap rounded border bg-background px-2 py-0.5 text-xs shadow">
{label}
</span>
)}
</div>
);
}
/** Sortable implementation — isolated so `useSortable` is never called conditionally. */
function DraggableSortableTaskNode({
taskId,
@@ -172,25 +293,148 @@ function useLevelDnd(tasks: TaskSwitcherItem[], onReorder?: (orderedTaskIds: str
* Renders one level of sibling task nodes. When `onReorder` is omitted the DnD
* scaffolding (and the misleading grab cursor) is skipped, so non-reorderable
* callers get a plain list with no wasted setup.
*
* By default each level owns its own `DndContext`, which confines every drag
* to its sibling level. When `externalDragContext` is set the caller already
* rendered a group-spanning `DndContext` (see `TaskTreeDndGroup`) so cross-
* level drops like nest-under can be resolved — this level only contributes
* its `SortableContext` and nodes.
*/
export function SortableTaskLevel({
tasks,
onReorder,
renderNode,
externalDragContext = false,
}: {
tasks: TaskSwitcherItem[];
onReorder?: (orderedTaskIds: string[]) => void;
renderNode: (task: TaskSwitcherItem, isDraggable: boolean) => React.ReactNode;
externalDragContext?: boolean;
}) {
const { sensors, handleDragEnd, sortableIds } = useLevelDnd(tasks, onReorder);
const isDraggable = !!onReorder;
const body = tasks.map((task) => renderNode(task, isDraggable));
if (!onReorder) return <>{body}</>;
const sortable = (
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{body}
</SortableContext>
);
if (externalDragContext) return sortable;
return (
<DndContext sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>
<SortableContext items={sortableIds} strategy={verticalListSortingStrategy}>
{body}
</SortableContext>
{sortable}
</DndContext>
);
}
/**
* One group-spanning DndContext for the sidebar task tree. Replaces the
* per-level contexts so a drag can target rows outside its own sibling level:
* reorder stays scoped to the level the drag started in (resolveSidebarDrop),
* and dropping on a `nest:` zone re-parents the dragged task.
*
* `children` receives the nest-target ids and the active drag id so rows can
* render their `NestDropZone` affordance only when they are valid targets.
*/
export function TaskTreeDndGroup({
groupTasks,
onReorderGroup,
onReorderSubtasks,
onNestTask,
children,
}: {
groupTasks: TaskSwitcherItem[];
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
onNestTask?: (activeTaskId: string, parentTaskId: string) => void;
children: (nestTargetIds: Set<string>, activeDragId: string | null) => React.ReactNode;
}) {
const [activeDragId, setActiveDragId] = useState<string | null>(null);
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.pointer,
}),
useSensor(TouchSensor, {
activationConstraint: TASK_SWITCHER_DRAG_ACTIVATION_CONSTRAINTS.touch,
}),
);
const enabled = !!onReorderGroup || !!onReorderSubtasks || !!onNestTask;
const { tasksById, groupRootIds, childrenByParent } = useMemo(() => {
const ids = new Set(groupTasks.map((t) => t.id));
const byParent = new Map<string, string[]>();
for (const t of groupTasks) {
if (t.parentTaskId && ids.has(t.parentTaskId)) {
const arr = byParent.get(t.parentTaskId) ?? [];
arr.push(t.id);
byParent.set(t.parentTaskId, arr);
}
}
return {
tasksById: new Map(groupTasks.map((t) => [t.id, t])),
groupRootIds: groupTasks
.filter((t) => !t.parentTaskId || !ids.has(t.parentTaskId))
.map((t) => t.id),
childrenByParent: byParent,
};
}, [groupTasks]);
const nestTargetIds = useMemo(
() => computeNestTargets(activeDragId ? tasksById.get(activeDragId) : undefined, groupTasks),
[activeDragId, tasksById, groupTasks],
);
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveDragId(String(event.active.id));
}, []);
const handleDragCancel = useCallback(() => setActiveDragId(null), []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
setActiveDragId(null);
const { active, over } = event;
if (!over) return;
const decision = resolveSidebarDrop({
activeId: String(active.id),
overId: String(over.id),
groupRootIds,
childrenByParent,
});
if (!decision) return;
switch (decision.kind) {
case "nest":
onNestTask?.(String(active.id), decision.parentTaskId);
break;
case "reorder-group":
onReorderGroup?.(decision.orderedTaskIds);
break;
case "reorder-subtasks":
onReorderSubtasks?.(decision.parentTaskId, decision.orderedTaskIds);
break;
}
},
[groupRootIds, childrenByParent, onReorderGroup, onReorderSubtasks, onNestTask],
);
const collisionDetection = useCallback<CollisionDetection>((args) => {
// A pointer resting on a nest zone always means "nest under this row";
// everywhere else falls back to the nearest-row reorder.
const within = pointerWithin(args);
const nest = within.filter((collision) =>
collision.id.toString().startsWith(NEST_DROPPABLE_PREFIX),
);
if (nest.length > 0) return nest;
return closestCenter(args);
}, []);
if (!enabled) return <>{children(new Set(), null)}</>;
return (
<DndContext
sensors={sensors}
collisionDetection={collisionDetection}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
{children(nestTargetIds, activeDragId)}
</DndContext>
);
}
+92 -21
View File
@@ -1,7 +1,13 @@
"use client";
import { useMemo } from "react";
import { countGroupTasks, type SidebarGroup } from "@/lib/sidebar/apply-view";
import { SortableTaskLevel, SortableTaskNode } from "./task-switcher-subtask-dnd";
import {
SortableTaskLevel,
SortableTaskNode,
TaskTreeDndGroup,
NestDropZone,
} from "./task-switcher-subtask-dnd";
import { GroupHeader } from "./task-switcher-group";
import { TaskRow, type SubtaskToggleInfo, type TaskRowProps } from "./task-switcher-row";
import type { TaskSwitcherItem } from "./task-switcher-types";
@@ -19,6 +25,13 @@ type TaskTreeContext = {
rowProps: TaskRowBaseProps;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
onNestTask?: (taskId: string, parentTaskId: string) => void;
// Rows in nestTargetIds render a nest drop zone while a drag is active.
nestTargetIds: Set<string>;
activeDragId: string | null;
// The group renders one DndContext spanning every level (TaskTreeDndGroup),
// so levels must not create their own.
externalDragContext: boolean;
};
function TaskTreeNode({
@@ -44,16 +57,22 @@ function TaskTreeNode({
}
: undefined;
const isRoot = depth === 0;
const isNestTarget = ctx.nestTargetIds.has(task.id);
const handle = (
<TaskRow
task={task}
depth={depth}
isSubTask={!isRoot}
subtaskToggle={toggleInfo}
isPinned={isRoot && ctx.pinnedSet.has(task.id)}
{...ctx.rowProps}
onTogglePin={isRoot ? ctx.rowProps.onTogglePin : undefined}
/>
// The relative wrapper keeps the nest drop zone pinned to this row's
// left edge rather than spanning the nested subtree below it.
<div className="relative">
<TaskRow
task={task}
depth={depth}
isSubTask={!isRoot}
subtaskToggle={toggleInfo}
isPinned={isRoot && ctx.pinnedSet.has(task.id)}
{...ctx.rowProps}
onTogglePin={isRoot ? ctx.rowProps.onTogglePin : undefined}
/>
{isNestTarget && <NestDropZone taskId={task.id} title={task.title} />}
</div>
);
const nested =
!subsHidden && hasSubs ? (
@@ -86,6 +105,7 @@ function TaskTreeLevel({
<SortableTaskLevel
tasks={tasks}
onReorder={onReorder}
externalDragContext={ctx.externalDragContext}
renderNode={(task, levelDraggable) => (
<TaskTreeNode
key={task.id}
@@ -117,8 +137,39 @@ export type GroupSectionProps = {
showHeader: boolean;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
onNestTask?: (taskId: string, parentTaskId: string) => void;
};
/**
* Flattens one group's subtree (roots + every descendant, in rendered order)
* so the group-spanning DndContext can resolve reorder levels and nest
* targets without consulting other groups' children. Tracks visited ids so a
* corrupted cycle in `subTasksByParentId` cannot recurse forever (mirrors
* countGroupTasks' protection).
*/
function flattenGroupTasks(
roots: TaskSwitcherItem[],
subTasksByParentId: Map<string, TaskSwitcherItem[]>,
): TaskSwitcherItem[] {
const out: TaskSwitcherItem[] = [];
const visited = new Set<string>();
const visit = (task: TaskSwitcherItem) => {
if (visited.has(task.id)) return;
visited.add(task.id);
out.push(task);
const subs = subTasksByParentId.get(task.id);
if (subs) {
for (const sub of subs) visit(sub);
}
};
for (const root of roots) visit(root);
return out;
}
/**
* Renders one sidebar group's task tree (header + the recursive tree wrapped
* in the group-spanning DndContext that enables nest-by-drag).
*/
export function GroupSection({
group,
subTasksByParentId,
@@ -131,17 +182,32 @@ export function GroupSection({
showHeader,
onReorderGroup,
onReorderSubtasks,
onNestTask,
}: GroupSectionProps) {
const totalCount = countGroupTasks(group.tasks, subTasksByParentId);
const ctx: TaskTreeContext = {
subTasksByParentId,
collapsedSubs: new Set(collapsedSubtaskParentIds ?? []),
onToggleSubtasks,
pinnedSet,
rowProps,
onReorderGroup,
onReorderSubtasks,
const groupTasks = useMemo(
() => flattenGroupTasks(group.tasks, subTasksByParentId),
[group.tasks, subTasksByParentId],
);
const renderTree = (nestTargetIds: Set<string>, activeDragId: string | null) => {
if (isCollapsed) return null;
const ctx: TaskTreeContext = {
subTasksByParentId,
collapsedSubs: new Set(collapsedSubtaskParentIds ?? []),
onToggleSubtasks,
pinnedSet,
rowProps,
onReorderGroup,
onReorderSubtasks,
onNestTask,
nestTargetIds,
activeDragId,
externalDragContext: true,
};
return <TaskTreeLevel parentTaskId={null} tasks={group.tasks} depth={0} ctx={ctx} />;
};
return (
<div>
{showHeader && (
@@ -153,9 +219,14 @@ export function GroupSection({
onToggle={onToggleCollapsed}
/>
)}
{!isCollapsed && (
<TaskTreeLevel parentTaskId={null} tasks={group.tasks} depth={0} ctx={ctx} />
)}
<TaskTreeDndGroup
groupTasks={groupTasks}
onReorderGroup={onReorderGroup}
onReorderSubtasks={onReorderSubtasks}
onNestTask={onNestTask}
>
{renderTree}
</TaskTreeDndGroup>
</div>
);
}
@@ -72,6 +72,8 @@ export type TaskSwitcherProps = {
onTogglePin?: (taskId: string) => void;
onReorderGroup?: (groupTaskIds: string[]) => void;
onReorderSubtasks?: (parentTaskId: string, orderedSubtaskIds: string[]) => void;
/** Re-parent a task under another task (drag onto a nest drop zone). */
onNestTask?: (taskId: string, parentTaskId: string) => void;
pinnedTaskIds?: string[];
deletingTaskId?: string | null;
isLoading?: boolean;
@@ -72,6 +72,7 @@ function buildGroupSectionProps(
showHeader,
onReorderGroup: props.onReorderGroup,
onReorderSubtasks: props.onReorderSubtasks,
onNestTask: props.onNestTask,
};
}
@@ -0,0 +1,110 @@
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Touch-drags a task row's handle onto a nest drop zone via CDP touch events.
* dnd-kit's TouchSensor needs the 250ms delay to elapse and then >5px of
* travel before the drag activates; the nest zone renders only once the drag
* is live, so its box is resolved mid-drag. The sheet scrolls, so both the
* source handle and the target zone are scrolled into view before their
* coordinates are read.
*/
async function touchDragRowToNestZone(
page: import("@playwright/test").Page,
cdp: import("@playwright/test").CDPSession,
handleLocator: ReturnType<Page["locator"]>,
zoneLocator: ReturnType<Page["locator"]>,
) {
await handleLocator.scrollIntoViewIfNeeded();
await page.waitForTimeout(100);
const handleBox = await handleLocator.boundingBox();
if (!handleBox) throw new Error("drag source handle has no bounding box");
const startX = handleBox.x + handleBox.width / 2;
const startY = handleBox.y + handleBox.height / 2;
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: startX, y: startY }],
});
// Hold still past the sensor delay, then move past the 5px tolerance to
// activate the drag.
await page.waitForTimeout(350);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: startX + 14, y: startY }],
});
await page.waitForTimeout(50);
await expect(zoneLocator).toBeVisible();
await zoneLocator.scrollIntoViewIfNeeded();
await page.waitForTimeout(50);
const to = await zoneLocator.boundingBox();
if (!to) throw new Error("nest drop zone has no bounding box");
const endX = to.x + to.width / 2;
const endY = to.y + to.height / 2;
for (let i = 1; i <= 10; i++) {
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [
{ x: startX + ((endX - startX) * i) / 10, y: startY + ((endY - startY) * i) / 10 },
],
});
}
await cdp.send("Input.dispatchTouchEvent", { type: "touchEnd", touchPoints: [] });
}
test.describe("Mobile subtask re-parenting by drag and drop", () => {
test("re-parents a subtask onto another root with a touch drag", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const oldParent = await apiClient.createTask(
seedData.workspaceId,
"Mobile reparent old parent",
placement,
);
const newParent = await apiClient.createTask(
seedData.workspaceId,
"Mobile reparent new parent",
placement,
);
const child = await apiClient.createTask(seedData.workspaceId, "Mobile reparent child", {
...placement,
parent_id: oldParent.id,
workspace_mode: "inherit_parent",
});
await testPage.goto(`/t/${newParent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await testPage.getByTestId("mobile-session-menu").click();
const taskSheet = testPage.getByRole("dialog", { name: "Tasks" });
const taskBlock = (taskId: string) =>
taskSheet.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
const cdp = await testPage.context().newCDPSession(testPage);
await touchDragRowToNestZone(
testPage,
cdp,
taskBlock(child.id).getByTestId("sortable-task-handle").first(),
taskBlock(newParent.id).getByTestId("nest-drop-zone"),
);
await expect(taskBlock(newParent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
timeout: 10_000,
});
await expect
.poll(async () => {
const task = await apiClient.getTask(child.id);
const workspace = task.metadata?.workspace as { mode?: string } | undefined;
return { parentId: task.parent_id ?? "", workspaceMode: workspace?.mode };
})
.toEqual({ parentId: newParent.id, workspaceMode: "shared_group" });
});
});
@@ -0,0 +1,263 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { SessionPage } from "../../pages/session-page";
/**
* Drags a sidebar task row's handle onto a nest drop zone using pointer
* events. dnd-kit's MouseSensor needs >8px of travel before the drag
* activates, and the nest zone only renders once the drag is live, so the
* target box is resolved mid-drag.
*/
async function dragRowToNestZone(
page: Page,
handleLocator: ReturnType<Page["locator"]>,
zoneLocator: ReturnType<Page["locator"]>,
) {
await handleLocator.scrollIntoViewIfNeeded();
await page.waitForTimeout(100);
const from = await handleLocator.boundingBox();
if (!from) throw new Error("drag source handle has no bounding box");
const startX = from.x + from.width / 2;
const startY = from.y + from.height / 2;
await page.mouse.move(startX, startY);
await page.mouse.down();
// Exceed the 8px activation distance so the drag starts.
await page.mouse.move(startX + 24, startY, { steps: 3 });
// The zone now exists; aim the pointer at its center.
await expect(zoneLocator).toBeVisible();
const to = await zoneLocator.boundingBox();
if (!to) throw new Error("nest drop zone has no bounding box");
await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2, { steps: 12 });
await page.mouse.up();
}
test.describe("Subtask re-parenting by drag and drop", () => {
test("re-parents an inherited-workspace subtask onto another root", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const oldParent = await apiClient.createTask(
seedData.workspaceId,
"Reparent old parent",
placement,
);
const newParent = await apiClient.createTask(
seedData.workspaceId,
"Reparent new parent",
placement,
);
const child = await apiClient.createTask(seedData.workspaceId, "Reparent child", {
...placement,
parent_id: oldParent.id,
workspace_mode: "inherit_parent",
});
await testPage.goto(`/t/${newParent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
const childHandle = taskBlock(child.id).getByTestId("sortable-task-handle");
const newParentZone = taskBlock(newParent.id).getByTestId("nest-drop-zone");
await dragRowToNestZone(testPage, childHandle, newParentZone);
// The tree moves live: child now sits inside the new parent's block.
await expect(taskBlock(newParent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
timeout: 10_000,
});
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1");
// The composite semantics (un-nest then nest) apply: parent re-pointed and
// the inherited workspace mode normalized to shared_group.
await expect
.poll(async () => {
const task = await apiClient.getTask(child.id);
const workspace = task.metadata?.workspace as { mode?: string } | undefined;
return { parentId: task.parent_id ?? "", workspaceMode: workspace?.mode };
})
.toEqual({ parentId: newParent.id, workspaceMode: "shared_group" });
});
test("nests a root task under another root", async ({ testPage, apiClient, seedData }) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, "Nest root parent", placement);
const child = await apiClient.createTask(seedData.workspaceId, "Nest root child", placement);
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "0");
await dragRowToNestZone(
testPage,
taskBlock(child.id).getByTestId("sortable-task-handle"),
taskBlock(parent.id).getByTestId("nest-drop-zone"),
);
await expect(taskBlock(parent.id).locator(`[data-task-id="${child.id}"]`)).toHaveCount(1, {
timeout: 10_000,
});
await expect.poll(async () => (await apiClient.getTask(child.id)).parent_id).toBe(parent.id);
});
test("offers no nest zones for a task that already has children", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parentWithChild = await apiClient.createTask(
seedData.workspaceId,
"Nestless parent",
placement,
);
await apiClient.createTask(seedData.workspaceId, "Nestless child", {
...placement,
parent_id: parentWithChild.id,
});
await testPage.goto(`/t/${parentWithChild.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Start a drag of the parent-with-child; no row may offer a nest zone.
const handle = taskBlock(parentWithChild.id).getByTestId("sortable-task-handle").first();
const box = await handle.boundingBox();
if (!box) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(box.x + box.width / 2 + 24, box.y + box.height / 2, { steps: 3 });
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
await testPage.keyboard.press("Escape");
await expect
.poll(async () => (await apiClient.getTask(parentWithChild.id)).parent_id ?? "")
.toBe("");
});
test("ignores a drop on a subtask row (not a valid target)", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parentA = await apiClient.createTask(seedData.workspaceId, "Invalid A parent", placement);
const parentB = await apiClient.createTask(seedData.workspaceId, "Invalid B parent", placement);
const childA = await apiClient.createTask(seedData.workspaceId, "Invalid A child", {
...placement,
parent_id: parentA.id,
});
const childB = await apiClient.createTask(seedData.workspaceId, "Invalid B child", {
...placement,
parent_id: parentB.id,
});
await testPage.goto(`/t/${parentA.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Drag childA toward childB: childB (a subtask) is not a valid target, so
// its row offers no nest zone (parentB's row does — it is a valid root
// candidate), and a drop on childB's row is cross-level: no-op.
const fromBox = await taskBlock(childA.id).getByTestId("sortable-task-handle").boundingBox();
if (!fromBox) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
steps: 3,
});
const zoneFor = (taskId: string) =>
session.sidebar.locator(`[data-testid="nest-drop-zone"][data-task-id="${taskId}"]`);
await expect(zoneFor(childB.id)).toHaveCount(0);
await expect(zoneFor(parentB.id)).toHaveCount(1);
const toBox = await taskBlock(childB.id).boundingBox();
if (!toBox) throw new Error("target row has no bounding box");
await testPage.mouse.move(toBox.x + toBox.width / 2, toBox.y + toBox.height / 2, { steps: 12 });
await testPage.mouse.up();
await expect.poll(async () => (await apiClient.getTask(childA.id)).parent_id).toBe(parentA.id);
});
test("sibling reorder still works inside a parent (regression)", async ({
testPage,
apiClient,
seedData,
}) => {
const placement = {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
};
const parent = await apiClient.createTask(seedData.workspaceId, "Reorder parent", placement);
const first = await apiClient.createTask(seedData.workspaceId, "Reorder first child", {
...placement,
parent_id: parent.id,
});
const second = await apiClient.createTask(seedData.workspaceId, "Reorder second child", {
...placement,
parent_id: parent.id,
});
await testPage.goto(`/t/${parent.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
const taskBlock = (taskId: string) =>
session.sidebar.locator(`[data-testid="sortable-task-block"][data-task-id="${taskId}"]`);
// Children render newest-first by default, so resolve the rendered order
// and drag the lower sibling onto the upper one's row (subtasks are not
// nest targets, so only the reorder path can fire).
const firstY = (await taskBlock(first.id).boundingBox())?.y ?? 0;
const secondY = (await taskBlock(second.id).boundingBox())?.y ?? 0;
const [upperId, lowerId] = firstY < secondY ? [first.id, second.id] : [second.id, first.id];
const upperBox = await taskBlock(upperId).boundingBox();
if (!upperBox) throw new Error("rows have no bounding boxes");
const fromBox = await taskBlock(lowerId).getByTestId("sortable-task-handle").boundingBox();
if (!fromBox) throw new Error("drag source handle has no bounding box");
await testPage.mouse.move(fromBox.x + fromBox.width / 2, fromBox.y + fromBox.height / 2);
await testPage.mouse.down();
await testPage.mouse.move(fromBox.x + fromBox.width / 2 + 24, fromBox.y + fromBox.height / 2, {
steps: 3,
});
await expect(session.sidebar.getByTestId("nest-drop-zone")).toHaveCount(0);
await testPage.mouse.move(upperBox.x + upperBox.width / 2, upperBox.y + upperBox.height / 2, {
steps: 12,
});
await testPage.mouse.up();
await expect
.poll(async () => {
const upperY = (await taskBlock(upperId).boundingBox())?.y ?? 0;
const lowerY = (await taskBlock(lowerId).boundingBox())?.y ?? 0;
return lowerY < upperY;
})
.toBe(true);
});
});
+34
View File
@@ -9,6 +9,20 @@ import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types";
type StoreApi = ReturnType<typeof useAppStoreApi>;
type SnapshotTask = WorkflowSnapshotData["tasks"][number];
/**
* Resolves the workflow id a task belongs to from the multi-workflow
* snapshots. Snapshot tasks carry no workflow_id themselves; the snapshot
* key is the workflow. Returns null when the task is not in any snapshot
* (the caller then skips the optimistic patch and lets the WS event
* reconcile state).
*/
export function taskWorkflowIdFromSnapshots(store: StoreApi, taskId: string): string | null {
const snapshots = store.getState().kanbanMulti?.snapshots ?? {};
return (
Object.keys(snapshots).find((wf) => snapshots[wf]?.tasks.some((t) => t.id === taskId)) ?? null
);
}
// One re-parent operation's context, threaded to the optimistic helpers.
type NestOp = {
store: StoreApi;
@@ -58,6 +72,26 @@ function rollbackParent(op: NestOp): void {
}
}
/**
* useNestTaskByDrag returns the drag-drop nesting action used by the sidebar
* and the mobile task sheet: it resolves the dragged task's workflow from the
* snapshot keys (snapshot tasks carry no workflow_id themselves) and runs the
* same composite nest operation the context menu uses. No-ops when the task
* is not in any snapshot (the WS event reconciles state either way).
*/
export function useNestTaskByDrag() {
const store = useAppStoreApi();
const nestTask = useNestTask();
return useCallback(
(taskId: string, parentTaskId: string) => {
const workflowId = taskWorkflowIdFromSnapshots(store, taskId);
if (!workflowId) return;
void nestTask(taskId, workflowId, parentTaskId);
},
[store, nestTask],
);
}
/**
* useNestTask returns a function that nests a task under a parent (or un-nests
* it when `parentId` is null). The multi-workflow snapshot may not be
+1
View File
@@ -40,6 +40,7 @@
"logOut": "Log out",
"maxViews_one": "{{count}} max",
"maxViews_other": "{{count}} max",
"nestUnder": "Nest under {{title}}",
"newKanbanWorkspace": "New kanban workspace",
"newOfficeWorkspace": "New office workspace",
"newTask": "New Task",
+1
View File
@@ -40,6 +40,7 @@
"logOut": "Ĺōĝ ōũţ",
"maxViews_one": "{{count}} ḿàx",
"maxViews_other": "{{count}} ḿàx",
"nestUnder": "Ńēśţ ũńďēŕ {{title}}",
"newKanbanWorkspace": "Ńēŵ ķàńƀàń ŵōŕķśƥàćē",
"newOfficeWorkspace": "Ńēŵ ōƒƒĩćē ŵōŕķśƥàćē",
"newTask": "Ńēŵ Ţàśķ",
+20
View File
@@ -9,6 +9,11 @@ export default defineConfig({
server: {
port: readPort(process.env.PORT),
strictPort: Boolean(process.env.PORT),
// Remote dev hosts (cloud VMs, LAN hostnames) are otherwise blocked by
// Vite's DNS-rebinding host check. Opt in per environment with an
// explicit comma-separated host list, e.g.
// KANDEV_VITE_ALLOWED_HOSTS="a.example.com,b.example.com".
allowedHosts: readAllowedHosts(process.env.KANDEV_VITE_ALLOWED_HOSTS),
},
preview: {
port: readPort(process.env.PORT),
@@ -57,3 +62,18 @@ function readPort(value: string | undefined): number | undefined {
const port = Number(value);
return Number.isInteger(port) && port > 0 ? port : undefined;
}
/**
* Reads the comma-separated KANDEV_VITE_ALLOWED_HOSTS override. Returns
* undefined (Vite's default host policy) when unset, or the explicit host
* list. Wildcards are deliberately unsupported: allowing every host would
* expose the dev server to DNS-rebinding attacks, so only named hosts can
* be opted in.
*/
function readAllowedHosts(value: string | undefined): string[] | undefined {
if (!value) return undefined;
return value
.split(",")
.map((host) => host.trim())
.filter(Boolean);
}
@@ -0,0 +1,120 @@
---
spec: docs/specs/tasks/subtask-reparenting-drag-drop.md
created: 2026-08-04
status: complete
---
# Implementation Plan: Subtask re-parenting by drag and drop
## Overview
The backend reparent contract (`PATCH /api/v1/tasks/:id` with `parent_id`, validated by `resolveParentID` + `validateReparentDepth`) already exists and is tested. Two deltas make drag-drop "strictly equivalent" to the menu composite (un-nest + nest): (1) normalize `inherit_parent` workspace mode to `shared_group` on any effective parent change in the canonical update path, and (2) add the same normalization to the Office dashboard PATCH for parity. The real work is frontend: hoist the sidebar tree's per-level `DndContext` into one per-group context, add nest drop zones with a pure drop-decision helper, and wire `onReparentTask` through `TaskSwitcher` to the existing `useNestTask` hook. E2E (desktop + mobile touch) comes last.
---
## Backend
### Canonical update normalization (`apps/backend/internal/task/service/service_tasks.go`)
In `Service.UpdateTask`, the parent block (~line 1225) already resolves and assigns `task.ParentID` only when the parent effectively changed. After that block, add a small helper `normalizeWorkspaceModeAfterReparent(task)` that copies `task.Metadata["workspace"]` and changes only `mode: "inherit_parent"` → `"shared_group"` — the exact detach semantics — applied whenever the effective parent changed (set or cleared). The existing full-row persist writes the metadata; the existing `parent_id` event emission (explicit nil on clear) is unchanged. This covers core PATCH, WS update, the sidebar menu, the Office parent picker (`updateTask`), and the drag-drop, all through one path.
### Office dashboard parity (`apps/backend/internal/office/dashboard/service_tasks.go`)
`DashboardService.UpdateTaskParentID` non-empty path currently writes `parent_id` directly and publishes `["parent_id"]`. Change it so that after the repo write it loads the task, and if workspace mode is `inherit_parent`, persists `shared_group` and publishes fields `["parent_id", "metadata"]`. Add `UpdateTaskWorkspaceMode(ctx, taskID, mode)` to the office sqlite repository (dialect-aware JSON set, mirroring `detachTaskQuery`'s mode branch) and to the `DashboardService` repo interface. The empty-parent path keeps routing to the canonical detacher.
### Events
No new event types. `task.updated` already carries `parent_id` (nil when cleared) and `metadata`; the normalization rides along. Office `OfficeTaskUpdated` gains `metadata` in its `fields` list only when the mode changed.
## Frontend
### Drop-decision logic (`apps/web/components/task/task-switcher-subtask-dnd.tsx`)
- Extract a pure, unit-testable `resolveSidebarDrop({ activeId, overId, groupRootIds, childrenByParent })` returning `{kind:"nest", parentTaskId}` | `{kind:"reorder", level:"group"|"subtasks", parentTaskId?, orderedTaskIds}` | `null`. `overId` prefixed `nest:` → nest; otherwise both tasks must share a level (group root list or one parent's children) → `arrayMove` within that level; anything else → `null`.
- Add `NestDropZone` (small component): `useDroppable({ id: "nest:"+taskId })`, absolute left-edge strip, `data-testid="nest-drop-zone"`, `data-task-id`, hover affordance label "Nest under <title>". Rendered only for rows that are valid targets of the active drag.
- Add a `computeNestTargets(activeTask, groupTasks)` helper: flatten the group's subtree, filter to the active task's `workflowId`, run the existing `computeNestCandidates` from `apps/web/lib/sidebar/nest-candidates.ts`. Returns the candidate id set (empty for tasks with children).
### DndContext hoisting (`task-switcher-subtask-dnd.tsx` + `apps/web/components/task/task-switcher.tsx`)
- `SortableTaskLevel` currently creates one `DndContext` per sibling level, which makes cross-level drops impossible. Change `SortableTaskLevel` so it renders only `SortableContext` + nodes when a group-level context is present (new prop, e.g. `externalDragContext`), preserving current behavior for non-reorderable callers.
- `GroupSection` in `task-switcher.tsx` renders ONE `DndContext` (sensors from `taskSwitcherDragActivationConstraints`, custom `collisionDetection`: `pointerWithin` filtered to `nest:` ids wins, else `closestCenter`, `onDragEnd` dispatching through `resolveSidebarDrop`).
- Track the active drag id (`onDragStart`) in the group; compute `computeNestTargets` and thread candidate ids through `TaskTreeContext` so `TaskTreeNode` renders `NestDropZone` on candidate rows.
- New `TaskSwitcher` prop `onReparentTask?: (taskId: string, parentTaskId: string) => void`, threaded through `GroupSection` → `TaskTreeContext` → drop handling.
### Sidebar + mobile wiring
- `apps/web/components/task/task-session-sidebar.tsx`: `handleReparentTask(taskId, parentTaskId)` resolves the task's `workflowId` from `displayTasks` and calls the existing `useNestTask` (non-null path → `updateTask(taskId, { parent_id })` → composite semantics after the backend change; same optimistic snapshot patch, rollback, and toast).
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx`: same wiring; touch drag already works via the existing `TouchSensor` (250 ms delay / 5 px tolerance).
### i18n
New user-facing copy (nest-zone label, aria text) goes through `t()` / `<Trans>` with keys added to the sidebar's locale namespace; do not hardcode.
## Tests
### Backend
- **What:** reparenting an `inherit_parent` child normalizes mode to `shared_group` (persisted + event metadata).
- **File:** `apps/backend/internal/task/service/service_reparent_test.go`
- **How:** extend the existing reparent fixture; assert `Metadata["workspace"]["mode"]` in the returned task, the persisted row, and the `task.updated` payload.
- **What:** non-`inherit_parent` modes (`shared_group`, `new_workspace`) and root-task reparents are untouched.
- **File:** same, table-driven.
- **What:** un-nest via `UpdateTask` (empty `parent_id`) normalizes like the detach endpoint.
- **File:** same.
- **What:** Office dashboard non-empty reparent normalizes `inherit_parent` and publishes `metadata` in fields.
- **File:** `apps/backend/internal/office/dashboard/service_detachment_test.go` (mirror `TestUpdateTaskParentIDUsesCanonicalDetacherWhenCleared`).
- Existing reparent/detach/handler suites must stay green (no changes to `resolveParentID` / `validateReparentDepth`).
### Frontend
- **What:** drop decision maps `nest:` over-ids and same-level reorders correctly, rejects cross-level drops.
- **File:** `apps/web/components/task/task-switcher-subtask-dnd.test.ts`
- **How:** pure-function unit tests for `resolveSidebarDrop`.
- **What:** nest targets = same-workflow roots per `computeNestCandidates`, empty for tasks with children.
- **File:** `apps/web/lib/sidebar/nest-candidates.test.ts` (extend) or a new sibling test.
- **What:** existing `TaskSwitcher` / sortable rendering behavior is preserved (regression).
- **File:** `apps/web/components/task/task-switcher.test.tsx`.
### E2E
See task 04. Desktop spec `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts` + mobile touch variant, modeled on `subtask-detachment.spec.ts` / `mobile-subtask-detachment.spec.ts` and the `Input.dispatchTouchEvent` pattern in `mobile-automations-scroll.spec.ts`.
## E2E Tests
- **Scenario:** subtask dragged onto another root's nest zone re-parents (API `parent_id` + sidebar nesting, no reload).
- **Scenario:** `inherit_parent` subtask ends with `shared_group` (assert via API metadata).
- **Scenario:** root dragged onto root nests; task with children offers no nest zone; drop on an invalid row is a no-op.
- **Scenario:** sibling reorder still works (regression).
- **Scenario:** mobile touch drag re-parents (mobile-chrome project).
- **File:** `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts`
## Verification Results
All task checks passed; see each task's `## Results` for exact commands and counts.
- Backend: `go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard` — service + office/dashboard `ok`; handlers `ok` under `umask 022` (3 pre-existing env failures under umask 002, verified on base).
- Web: full unit suite passes (8441+ of 8450; the 5 failures are pre-existing env issues: 3 Docker-bridge `http-git-server`, 2 load-sensitive file-browser timeouts that pass in isolation); typecheck, lint, i18n ratchet clean.
- E2E: `subtask-reparent-drag-drop.spec.ts` 5/5 (chromium), `mobile-subtask-reparent-drag-drop.spec.ts` 1/1 (mobile-chrome touch).
- Root: `make fmt`, `make typecheck`, `make lint` pass. `make test` residual failures are pre-existing environment-dependent tests (launchd/systemd/cli-shim) unrelated to this feature.
## Implementation Waves And Parallel Candidates
```
Wave 1:
- [x] [task-01-backend-composite-semantics](task-01-backend-composite-semantics.md)
Wave 2:
- [x] [task-02-frontend-nest-drop-infrastructure](task-02-frontend-nest-drop-infrastructure.md)
Wave 3:
- [x] [task-03-frontend-wiring-and-affordances](task-03-frontend-wiring-and-affordances.md)
Wave 4:
- [x] [task-04-e2e-and-verification](task-04-e2e-and-verification.md)
```
All tasks are sequential (each depends on the previous). No parallel candidates.
## Open Questions
None.
@@ -0,0 +1,53 @@
---
id: "01-backend-composite-semantics"
title: "Backend composite re-parent semantics"
status: done
wave: 1
depends_on: []
plan: "plan.md"
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
---
# Task 01: Backend composite re-parent semantics
## Acceptance
- `Service.UpdateTask` normalizes `metadata.workspace.mode` from `inherit_parent` to `shared_group` whenever the effective parent changes (set or cleared); other modes and root tasks are untouched. The returned task, the persisted row, and the `task.updated` payload all reflect the change.
- `DashboardService.UpdateTaskParentID` (Office dashboard PATCH) applies the same normalization on its non-empty parent path and includes `metadata` in the published `fields` when the mode changed.
- All existing reparent/detach service and handler tests pass unchanged.
## Verification
```bash
cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard
```
## Files likely touched
- `apps/backend/internal/task/service/service_tasks.go` (parent block in `Service.UpdateTask`, new `normalizeWorkspaceModeAfterReparent` helper)
- `apps/backend/internal/task/service/service_reparent_test.go` (new cases)
- `apps/backend/internal/office/dashboard/service_tasks.go` (`UpdateTaskParentID`)
- `apps/backend/internal/office/dashboard/service_detachment_test.go` (parity test)
- `apps/backend/internal/office/repository/sqlite/tasks.go` (new `UpdateTaskWorkspaceMode`)
- `apps/backend/internal/office/dashboard/service.go` (repo interface member, if `UpdateTaskWorkspaceMode` is not already surfaced)
## Dependencies
None.
## Inputs
- Spec sections: What (composite semantics), Data model, API surface, Failure modes.
- Existing pattern: `Service.DetachTask` + `detachTaskQuery` (dialect-aware JSON mode normalization in `apps/backend/internal/task/repository/sqlite/task.go`), `resolveParentID` / `validateReparentDepth` in `service_tasks.go`, `publishTaskUpdated` in the office dashboard service.
- Do NOT change `resolveParentID` / `validateReparentDepth`; validation behavior is already shipped and tested.
## Output contract
Report the service/repo changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
## Results
- `cd apps/backend && go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard` — service and office/dashboard `ok`; handlers `ok` (run with `umask 022`; this sandbox's default umask 002 makes 3 pre-existing local-repository handler tests fail — they pass under standard umask and fail identically on the stash-clean base).
- Added `normalizeWorkspaceModeAfterReparent` to `Service.UpdateTask` (parent-change block): `inherit_parent` → `shared_group` on any effective parent change. TDD red→green: `TestService_UpdateTask_ReparentNormalizesInheritedWorkspaceMode`, `TestService_UpdateTask_UnnestNormalizesInheritedWorkspaceMode`, `TestService_UpdateTask_ReparentPreservesNonInheritedWorkspaceModes`.
- Office parity: repo `UpdateTaskParentID` now sets `parent_id` AND normalizes mode in one dialect-aware UPDATE (SQLite JSON, mirroring `detachTaskQuery`); dashboard service publishes `["parent_id", "metadata"]`; parity test `TestUpdateTaskParentIDNormalizesInheritedWorkspaceMode`; added the missing `metadata` column to the office test-harness tasks DDL (matches production schema).
- Files changed: `internal/task/service/service_tasks.go`, `service_reparent_test.go`, `internal/office/dashboard/service_tasks.go`, `service_detachment_test.go`, `handler_test.go`, `internal/office/repository/sqlite/tasks.go`.
@@ -0,0 +1,51 @@
---
id: "02-frontend-nest-drop-infrastructure"
title: "Frontend nest-drop infrastructure"
status: done
wave: 2
depends_on: ["01-backend-composite-semantics"]
plan: "plan.md"
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
---
# Task 02: Frontend nest-drop infrastructure
## Acceptance
- A single `DndContext` per sidebar group section replaces the per-level contexts, so a drag can target rows outside its own sibling level; sibling reorder behavior is unchanged.
- `resolveSidebarDrop` (pure) and `computeNestTargets` (pure) exist with unit tests; `NestDropZone` renders on candidate rows with `data-testid="nest-drop-zone"` and `data-task-id`.
- Existing `TaskSwitcher` / `SortableTaskLevel` tests pass (no behavior regression when no group-level context or reorder is configured).
## Verification
```bash
cd apps/web && pnpm test -- components/task/task-switcher-subtask-dnd.test.ts components/task/task-switcher.test.tsx lib/sidebar/nest-candidates.test.ts
cd apps/web && pnpm run typecheck
```
## Files likely touched
- `apps/web/components/task/task-switcher-subtask-dnd.tsx` — hoist `DndContext` out of `SortableTaskLevel` (new `externalDragContext` mode), add `NestDropZone`, `resolveSidebarDrop`, `computeNestTargets`, custom collision (`pointerWithin` → `nest:` ids, else `closestCenter`), sensors, group-level `onDragStart`/`onDragEnd`.
- `apps/web/components/task/task-switcher.tsx` — `GroupSection` renders the hoisted `DndContext`; `TaskTreeContext` carries candidate ids + `onReparentTask`; `TaskTreeNode` renders `NestDropZone` on candidate rows; new optional `onReparentTask` prop on `TaskSwitcher` (unwired for now).
- `apps/web/components/task/task-switcher-subtask-dnd.test.ts` (new) — `resolveSidebarDrop` unit tests: nest id mapping, group-root reorder, subtask-level reorder, cross-level drop → null, no-over → null.
## Dependencies
Task 01 (semantics only — infra works against the existing `updateTask` path regardless).
## Inputs
- Spec sections: What (gesture, targets, reorder preservation), Scenarios.
- Existing code: `useLevelDnd` / `SortableTaskLevel` / `DraggableSortableTaskNode` in `task-switcher-subtask-dnd.tsx`; `TaskTreeLevel`/`GroupSection`/`TaskTreeContext` in `task-switcher.tsx`; `computeNestCandidates` in `apps/web/lib/sidebar/nest-candidates.ts`; activation constraints constants.
- Reorder callbacks keep their signatures: `onReorderGroup(orderedTaskIds)`, `onReorderSubtasks(parentTaskId, orderedTaskIds)`.
## Output contract
Report the component/helper changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
## Results
- `cd apps/web && pnpm test -- components/task/task-switcher-subtask-dnd.test.ts components/task/task-switcher.test.tsx components/task/task-switcher-direct-subtasks.test.tsx` — 31 passed (new helper tests + switcher regression suites).
- `pnpm run typecheck` clean; `pnpm run lint` clean (0 warnings); `pnpm run i18n:ratchet` clean.
- Hoisted per-level `DndContext` into `TaskTreeDndGroup` (one per group section; custom collision: `pointerWithin` → `nest:` ids win, else `closestCenter`); `SortableTaskLevel` gained `externalDragContext`; added `NestDropZone` + pure `resolveSidebarDrop`/`computeNestTargets` (unit-tested); `GroupSection` slimmed via props-object + new `task-switcher-tree-context.ts` (`flattenGroupTasks`/`buildTaskTreeContext`) to stay under lint line limits.
- Files changed: `components/task/task-switcher-subtask-dnd.tsx`, `task-switcher-subtask-dnd.test.ts`, `task-switcher.tsx`, `task-switcher-tree-context.ts` (new), `src/locales/en/sidebar.json` (`sidebar:nestUnder`).
@@ -0,0 +1,55 @@
---
id: "03-frontend-wiring-and-affordances"
title: "Frontend wiring and affordances"
status: done
wave: 3
depends_on: ["02-frontend-nest-drop-infrastructure"]
plan: "plan.md"
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
---
# Task 03: Frontend wiring and affordances
## Acceptance
- Desktop sidebar and mobile task switcher sheet wire `onReparentTask` through the existing `useNestTask` hook: a drop on a nest zone calls `updateTask(taskId, { parent_id })` with the task's `workflowId`, applies the optimistic snapshot patch, rolls back + toasts on error, and reconciles via the `task.updated` WS event.
- The nest-zone affordance shows `Nest under <title>` (localized via `t()`), and the drag exposes no nest zone on invalid targets.
- All new user-facing copy is i18n'd; `pnpm run i18n:ratchet` is clean for touched lines.
## Verification
```bash
cd apps/web && pnpm test -- components/task/task-session-sidebar.test.tsx components/task/mobile/session-task-switcher-sheet.test.tsx
cd apps/web && pnpm run typecheck
cd apps/web && pnpm run lint
cd apps/web && pnpm run i18n:ratchet
```
(Adjust test file list to the files that actually exist for these components; if a surface has no test file, add focused tests for the new handler.)
## Files likely touched
- `apps/web/components/task/task-session-sidebar.tsx` — `handleReparentTask(taskId, parentTaskId)` resolving `workflowId` from `displayTasks`, wired to `onReparentTask` via `buildTaskSwitcherProps`.
- `apps/web/components/task/task-session-sidebar-switcher-props.ts` — thread the new prop.
- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` — same wiring (shared `TaskSwitcher`; touch drag already active).
- `apps/web/src/locales/en/<sidebar-namespace>.json` — new keys (`nestUnder`, aria label); use the namespace already used by the sidebar components.
- `apps/web/components/task/task-switcher-subtask-dnd.tsx` — affordance label rendering (if not already in task 02).
## Dependencies
Task 02 (props/context plumbing) and Task 01 (composite semantics behind `updateTask`).
## Inputs
- Spec sections: What (single API call, optimistic update, no confirmation), Scenarios (invalid target toast, mobile).
- Existing code: `useNestTask` in `apps/web/hooks/use-nest-task.ts` (non-null path already calls `updateTask`), `buildTaskSwitcherProps`, mobile sheet's existing `TaskSwitcher` usage.
## Output contract
Report wiring changes, exact commands and results, files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
## Results
- `pnpm run typecheck` clean; `pnpm run lint` clean; `pnpm run i18n:ratchet` clean; task-component suites passed (249 files / 2140 tests in the `components/task` run).
- Desktop sidebar and mobile sheet wire `onNestTask` through the existing `useNestTask` hook; workflow resolved from snapshot keys via the new shared helper `taskWorkflowIdFromSnapshots` in `hooks/use-nest-task.ts`; `MobileTaskList` gained `onNestTask` (sheet stays open on drop); new `sidebar:nestUnder` key.
- Files changed: `components/task/task-session-sidebar.tsx`, `task-session-sidebar-switcher-props.ts`, `components/task/mobile/session-task-switcher-sheet.tsx`, `session-task-switcher-sheet-hooks.ts`, `hooks/use-nest-task.ts`.
@@ -0,0 +1,50 @@
---
id: "04-e2e-and-verification"
title: "E2E and full verification"
status: done
wave: 4
depends_on: ["03-frontend-wiring-and-affordances"]
plan: "plan.md"
spec: "../../specs/tasks/subtask-reparenting-drag-drop.md"
---
# Task 04: E2E and full verification
## Acceptance
- Desktop Playwright spec covers: subtask dragged onto another root's nest zone re-parents (assert API `parent_id` and sidebar nesting, no reload); `inherit_parent` subtask ends with `shared_group` (assert via API metadata); root dragged onto root nests; a task with children offers no nest zone; a drop on an invalid row is a no-op; sibling reorder still works.
- Mobile-chrome touch variant covers the same re-parent flow via `Input.dispatchTouchEvent`.
- `make fmt` then `make typecheck test lint` pass from the repository root.
## Verification
```bash
cd apps/web && pnpm e2e:run e2e/tests/task/subtask-reparent-drag-drop.spec.ts
cd /home/clem/.kandev/tasks/re-parenting-from-dr_0b8/kdlbs-kandev && make fmt && make typecheck && make test && make lint
```
Note: run `pnpm install --frozen-lockfile` from `apps/` once if this is a fresh worktree (missing `node_modules` breaks pnpm commands and the commit hook).
## Files likely touched
- `apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts` (new) — model structure, seeding, and sidebar interaction on `apps/web/e2e/tests/task/subtask-detachment.spec.ts`; drag mechanics via `locator.hover()` + `page.mouse.down()` + stepped `mouse.move()` + `mouse.up()` (dnd-kit needs >8px movement before activation).
- Mobile variant: same spec gated to `mobile-chrome` project or a sibling spec, using the `Input.dispatchTouchEvent` pattern from `apps/web/e2e/tests/mobile-automations-scroll.spec.ts`.
## Dependencies
Tasks 01–03.
## Inputs
- Spec Scenarios (each maps to a test), plan E2E section, existing `subtask-detachment` e2e specs and helpers (`apps/web/e2e/helpers/api-client.ts` createTask/updateTask with `parent_id` and `workspace_mode`).
## Output contract
Report the spec files, exact commands and results (including per-scenario pass counts), files changed, blockers, residual risks; update this task and `plan.md` when acceptance passes.
## Results
- Desktop: `pnpm e2e:run tests/task/subtask-reparent-drag-drop.spec.ts --shards 1` — 5 passed (subtask→root reparent + workspace-mode normalization, root→root nest, no nest zones for a task with children, subtask-row drop no-op, sibling reorder regression).
- Mobile: `pnpm e2e:run --project mobile-chrome tests/task/mobile-subtask-reparent-drag-drop.spec.ts --shards 1` — 1 passed (touch drag via CDP `Input.dispatchTouchEvent`; source handle and target zone are scrolled into view first — the sheet scrolls and off-viewport coordinates make raw mouse/touch events no-ops).
- Root verification: `make fmt`, `make typecheck`, `make lint` pass (umask 022). `make test`: all changed packages pass; residual failures are pre-existing environment issues (launchd/systemd/cli-shim backend tests, Docker-bridge-dependent `http-git-server.test.ts`, load-sensitive file-browser timeouts that pass in isolation) — verified failing on the stash-clean base.
- Files changed: `e2e/tests/task/subtask-reparent-drag-drop.spec.ts` (new), `e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts` (new). Temporary diagnostic spec `mobile-drag-diag.spec.ts` created and removed.
+1
View File
@@ -68,6 +68,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
| [remote-contribution-tasks](tasks/remote-contribution-tasks.md) | approved |
| [subtask-checklist](tasks/subtask-checklist.md) | shipped |
| [subtask-detachment](tasks/subtask-detachment.md) | shipped |
| [subtask-reparenting-drag-drop](tasks/subtask-reparenting-drag-drop.md) | building |
| [subtask-completion-trigger](tasks/subtask-completion-trigger.md) | draft |
| [subtree-controls](tasks/subtree-controls.md) | shipped |
| [blocked-task-escalation](tasks/blocked-task-escalation.md) | draft |
+1 -1
View File
@@ -79,7 +79,7 @@ The cleared parent relationship and normalized workspace mode survive backend re
## Out of scope
- Reparenting a task to a different parent from the sidebar or kanban card menus.
- Reparenting a task to a different parent from the sidebar or kanban card menus (now covered by [subtask-reparenting-drag-drop](subtask-reparenting-drag-drop.md) for the sidebar; kanban-card reparenting remains out of scope).
- Copying or provisioning a separate workspace during detachment.
- Removing blocker relationships or changing descendant relationships.
- Stopping or restarting active sessions.
@@ -0,0 +1,72 @@
---
status: building
created: 2026-08-04
owner: kandev
---
# Subtask re-parenting by drag and drop
## Why
Users can detach a subtask or nest a task under another via context-menu actions, but moving a task from under one parent to under another takes two menu hops (un-nest, then nest under), and the sidebar's drag-and-drop only reorders siblings. A direct drag gesture should re-parent a task in one motion with the exact same result as those two menu actions.
## What
- The sidebar task tree (desktop sidebar and the mobile task switcher sheet) lets a user re-parent a task by dragging its row onto another row's **nest drop zone**.
- The result is strictly equivalent to choosing `Un-nest (remove parent)` then `Nest under <target>` from the task's context menu: the task's parent becomes the target, and a task whose workspace mode is `inherit_parent` ends with mode `shared_group` — its materialized workspace and workspace-group membership are unchanged.
- Drop targets are exactly the candidates the context menu offers (the `computeNestCandidates` rules): same-workflow root tasks in the visible group, excluding the dragged task, its current parent, and any subtask (which also excludes the task's own descendants). A task that already has children offers no nest targets.
- While a drag with valid targets is active, candidate rows show a nest drop zone (a left-edge strip) with a `Nest under <title>` affordance. Dropping on a zone re-parents; dropping between rows keeps the existing sibling-reorder behavior; any other drop is a no-op.
- Re-parenting is a single API call on the existing canonical path (`PATCH /api/v1/tasks/:id` with `parent_id`), which already rejects self-parenting, missing/archived/cross-workspace targets, descendant cycles, and one-level-depth violations for kanban tasks.
- The sidebar `Nest under` menu, the Office parent picker, the WS task-update path, and the Office dashboard PATCH all share the same composite semantics: any effective parent change normalizes an `inherit_parent` workspace mode to `shared_group`.
- No confirmation dialog is shown on drop (unlike the detach menu action): the gesture is explicit, the operation is non-destructive (workspace membership is retained) and reversible via the menu or another drag.
- Successful re-parenting is reflected across sidebar, board, and task-detail views without a reload, via the existing optimistic snapshot update and the `task.updated` WebSocket event.
- The mobile task switcher sheet offers the same touch drag-and-drop re-parenting.
## Data model
No table or column is added. Re-parenting updates existing persisted fields in the same task-row write:
- `tasks.parent_id` becomes the target task's id (the previous parent, if any, is replaced).
- `tasks.metadata.workspace.mode` changes from `inherit_parent` to `shared_group` when the parent relationship effectively changes and the mode was `inherit_parent`. Other modes (`shared_group`, `new_workspace`) are unchanged.
- `task_workspace_group_members` is unchanged; active membership remains the durable source of shared workspace access.
- Descendant `parent_id` values, blockers, sessions, repositories, workflow, workflow step, and state are unchanged.
## API surface
No new endpoint. Reuses and extends existing contracts:
- `PATCH /api/v1/tasks/:id` with `parent_id` (non-empty nests; `""` un-nests) — already validated by `Service.resolveParentID` (self, existence, archived, same workspace, descendant cycle) and `validateReparentDepth` (one-level kanban limit, Office trees exempt). **Behavior addition:** when the effective parent changes, `inherit_parent` workspace mode is normalized to `shared_group` (mirroring the detach operation). Success returns the updated task DTO; invalid targets map to `400`; missing task to `404`.
- `PATCH /api/v1/office/tasks/:id` with non-empty `parent_id` — same normalization added for parity; empty parent continues to route through the canonical detach operation.
- WS `task.updated` payload unchanged: `parent_id` is always present (nil when cleared), and `metadata` carries the normalized workspace mode.
## Failure modes
- **Invalid target** (self, descendant, subtask, archived, missing, cross-workspace, or depth violation): the UI filters these targets out before a drop can land, so a drop outside every nest zone is a no-op. If a request is nevertheless rejected by the backend (e.g. a valid-zone target became invalid between render and drop), the UI keeps the task in its original tree position, rolls back the optimistic update, and shows a request-error toast.
- **Persistence failure**: no successful response is returned; the UI rolls back to the original tree.
- **Concurrent submissions** are safe: setting the same parent twice is idempotent, and the optimistic update is reconciled by the authoritative `task.updated` event.
- **No valid targets**: the drag offers no nest zones; only reorder remains possible.
## Scenarios
- **GIVEN** a subtask `C` under parent `A` and a root task `B` in the same workflow, **WHEN** the user drags `C` onto `B`'s nest drop zone, **THEN** `C`'s parent becomes `B`, the sidebar shows `C` nested under `B`, and no reload occurs.
- **GIVEN** an `inherit_parent` subtask `C` under `A`, **WHEN** `C` is dragged onto root `B`'s nest zone, **THEN** `C`'s persisted workspace mode is `shared_group` and its workspace-group membership is unchanged.
- **GIVEN** a root task with no children, **WHEN** it is dragged onto another root's nest zone, **THEN** it becomes that root's subtask.
- **GIVEN** a task that already has children, **WHEN** it is dragged, **THEN** no row offers a nest drop zone (reorder only).
- **GIVEN** a drag over a subtask row or over a task in a different workflow, **WHEN** the pointer rests on the row, **THEN** no nest drop zone is offered.
- **GIVEN** a subtask dragged toward its current parent, **WHEN** the pointer rests on that parent row, **THEN** no nest drop zone is offered.
- **GIVEN** a drag dropped between two sibling rows, **WHEN** the drop lands outside every nest zone, **THEN** the siblings reorder as before.
- **GIVEN** a drop that lands outside every nest zone, **WHEN** the drop completes, **THEN** the task keeps its original parent and no request is sent (a plain no-op); a request-error toast appears only when a valid-zone drop's request is rejected by the backend.
- **GIVEN** a re-parented task, **WHEN** its `task.updated` event arrives over WebSocket, **THEN** cached parent relationships in sidebar, board, and task-detail views are updated.
- **GIVEN** the mobile task switcher sheet, **WHEN** a user touch-drags a subtask onto a root's nest zone, **THEN** the same re-parenting occurs.
## Out of scope
- Nesting via drag on the kanban board (cards keep their step-move drag semantics).
- Lifting the one-level kanban subtask limit (still enforced by `computeNestCandidates` and `validateReparentDepth`; Office trees keep arbitrary depth).
- Drag-to-re-parent in the Office task list (Office keeps its parent picker).
- Bulk or multi-select drag re-parenting.
- A keyboard/AT drag gesture; the existing context menus remain the accessible path to the same operation.
## Implementation plan
See [the implementation plan](../../plans/subtask-reparenting-drag-drop/plan.md).