From 4d80edacf24fbbde2fb9db0144efa907f71ace76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cl=C3=A9ment=20Fiere?= Date: Fri, 7 Aug 2026 16:33:41 +0200 Subject: [PATCH] feat: re-parent tasks by drag and drop in the sidebar (#2355) --- .../internal/office/dashboard/handler_test.go | 1 + .../dashboard/service_detachment_test.go | 100 +++++++ .../office/dashboard/service_tasks.go | 20 +- .../office/repository/sqlite/tasks.go | 33 ++- .../task/service/service_reparent_test.go | 129 +++++++++ .../internal/task/service/service_tasks.go | 21 ++ .../session-task-switcher-sheet-hooks.ts | 11 + .../mobile/session-task-switcher-sheet.tsx | 147 +++++----- .../task-session-sidebar-switcher-props.ts | 2 + .../components/task/task-session-sidebar.tsx | 4 + .../task/task-switcher-subtask-dnd.test.ts | 167 +++++++++-- .../task/task-switcher-subtask-dnd.tsx | 252 ++++++++++++++++- .../components/task/task-switcher-tree.tsx | 113 ++++++-- .../components/task/task-switcher-types.ts | 2 + apps/web/components/task/task-switcher.tsx | 1 + .../mobile-subtask-reparent-drag-drop.spec.ts | 110 ++++++++ .../task/subtask-reparent-drag-drop.spec.ts | 263 ++++++++++++++++++ apps/web/hooks/use-nest-task.ts | 34 +++ apps/web/src/locales/en/sidebar.json | 1 + apps/web/src/locales/pseudo/sidebar.json | 1 + apps/web/vite.config.ts | 20 ++ .../subtask-reparenting-drag-drop/plan.md | 120 ++++++++ .../task-01-backend-composite-semantics.md | 53 ++++ ...sk-02-frontend-nest-drop-infrastructure.md | 51 ++++ ...task-03-frontend-wiring-and-affordances.md | 55 ++++ .../task-04-e2e-and-verification.md | 50 ++++ docs/specs/INDEX.md | 1 + docs/specs/tasks/subtask-detachment.md | 2 +- .../tasks/subtask-reparenting-drag-drop.md | 72 +++++ 29 files changed, 1721 insertions(+), 115 deletions(-) create mode 100644 apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts create mode 100644 apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts create mode 100644 docs/plans/subtask-reparenting-drag-drop/plan.md create mode 100644 docs/plans/subtask-reparenting-drag-drop/task-01-backend-composite-semantics.md create mode 100644 docs/plans/subtask-reparenting-drag-drop/task-02-frontend-nest-drop-infrastructure.md create mode 100644 docs/plans/subtask-reparenting-drag-drop/task-03-frontend-wiring-and-affordances.md create mode 100644 docs/plans/subtask-reparenting-drag-drop/task-04-e2e-and-verification.md create mode 100644 docs/specs/tasks/subtask-reparenting-drag-drop.md diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index bc7bb714c9..49805857f5 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -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', diff --git a/apps/backend/internal/office/dashboard/service_detachment_test.go b/apps/backend/internal/office/dashboard/service_detachment_test.go index 9dd63b1dc9..4ad705dbb6 100644 --- a/apps/backend/internal/office/dashboard/service_detachment_test.go +++ b/apps/backend/internal/office/dashboard/service_detachment_test.go @@ -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) + } +} diff --git a/apps/backend/internal/office/dashboard/service_tasks.go b/apps/backend/internal/office/dashboard/service_tasks.go index 346504c972..87bcf1a285 100644 --- a/apps/backend/internal/office/dashboard/service_tasks.go +++ b/apps/backend/internal/office/dashboard/service_tasks.go @@ -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 } diff --git a/apps/backend/internal/office/repository/sqlite/tasks.go b/apps/backend/internal/office/repository/sqlite/tasks.go index 222a1c525c..c455a395a7 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks.go +++ b/apps/backend/internal/office/repository/sqlite/tasks.go @@ -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. diff --git a/apps/backend/internal/task/service/service_reparent_test.go b/apps/backend/internal/task/service/service_reparent_test.go index 2dd4bca520..badd57db3c 100644 --- a/apps/backend/internal/task/service/service_reparent_test.go +++ b/apps/backend/internal/task/service/service_reparent_test.go @@ -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) + }) + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index a9cf24ce41..6927e09be9 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -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 diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts index 9bcd410874..787939e540 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts +++ b/apps/web/components/task/mobile/session-task-switcher-sheet-hooks.ts @@ -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, diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 5cae6c58c1..d6b475e469 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -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; @@ -98,6 +73,7 @@ export function MobileTaskList({ onArchiveTask: (taskId: string) => void; onDeleteTask: (taskId: string) => Promise | void; onDetachTask: (taskId: string) => Promise | 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 ( - + [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 ; } 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, diff --git a/apps/web/components/task/task-session-sidebar-switcher-props.ts b/apps/web/components/task/task-session-sidebar-switcher-props.ts index 287b03fd5f..9401387301 100644 --- a/apps/web/components/task/task-session-sidebar-switcher-props.ts +++ b/apps/web/components/task/task-session-sidebar-switcher-props.ts @@ -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, diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index e5b8817027..c05da3d9a4 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -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, diff --git a/apps/web/components/task/task-switcher-subtask-dnd.test.ts b/apps/web/components/task/task-switcher-subtask-dnd.test.ts index b2bf16bb89..2755231701 100644 --- a/apps/web/components/task/task-switcher-subtask-dnd.test.ts +++ b/apps/web/components/task/task-switcher-subtask-dnd.test.ts @@ -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([ + [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); }); }); diff --git a/apps/web/components/task/task-switcher-subtask-dnd.tsx b/apps/web/components/task/task-switcher-subtask-dnd.tsx index baf9904238..edcdec6785 100644 --- a/apps/web/components/task/task-switcher-subtask-dnd.tsx +++ b/apps/web/components/task/task-switcher-subtask-dnd.tsx @@ -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:` 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:`. */ +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; +}): 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 | undefined, + groupTasks: TaskSwitcherItem[], +): Set { + 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 ( +
+ {isOver && ( + + {label} + + )} +
+ ); +} + /** 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 = ( + + {body} + + ); + if (externalDragContext) return sortable; return ( - - {body} - + {sortable} + + ); +} + +/** + * 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, activeDragId: string | null) => React.ReactNode; +}) { + const [activeDragId, setActiveDragId] = useState(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(); + 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((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 ( + + {children(nestTargetIds, activeDragId)} ); } diff --git a/apps/web/components/task/task-switcher-tree.tsx b/apps/web/components/task/task-switcher-tree.tsx index dbe6332030..478a6ed6cd 100644 --- a/apps/web/components/task/task-switcher-tree.tsx +++ b/apps/web/components/task/task-switcher-tree.tsx @@ -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; + 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 = ( - + // The relative wrapper keeps the nest drop zone pinned to this row's + // left edge rather than spanning the nested subtree below it. +
+ + {isNestTarget && } +
); const nested = !subsHidden && hasSubs ? ( @@ -86,6 +105,7 @@ function TaskTreeLevel({ ( 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, +): TaskSwitcherItem[] { + const out: TaskSwitcherItem[] = []; + const visited = new Set(); + 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, 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 ; }; + return (
{showHeader && ( @@ -153,9 +219,14 @@ export function GroupSection({ onToggle={onToggleCollapsed} /> )} - {!isCollapsed && ( - - )} + + {renderTree} +
); } diff --git a/apps/web/components/task/task-switcher-types.ts b/apps/web/components/task/task-switcher-types.ts index 3168da3e63..80d3d35767 100644 --- a/apps/web/components/task/task-switcher-types.ts +++ b/apps/web/components/task/task-switcher-types.ts @@ -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; diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index f92e3fd1c5..06402ff620 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -72,6 +72,7 @@ function buildGroupSectionProps( showHeader, onReorderGroup: props.onReorderGroup, onReorderSubtasks: props.onReorderSubtasks, + onNestTask: props.onNestTask, }; } diff --git a/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts b/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts new file mode 100644 index 0000000000..8df635a326 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-subtask-reparent-drag-drop.spec.ts @@ -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, + zoneLocator: ReturnType, +) { + 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" }); + }); +}); diff --git a/apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts b/apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts new file mode 100644 index 0000000000..d03f85c7a4 --- /dev/null +++ b/apps/web/e2e/tests/task/subtask-reparent-drag-drop.spec.ts @@ -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, + zoneLocator: ReturnType, +) { + 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); + }); +}); diff --git a/apps/web/hooks/use-nest-task.ts b/apps/web/hooks/use-nest-task.ts index 3ab3fe7282..c8dd28c993 100644 --- a/apps/web/hooks/use-nest-task.ts +++ b/apps/web/hooks/use-nest-task.ts @@ -9,6 +9,20 @@ import type { WorkflowSnapshotData } from "@/lib/state/slices/kanban/types"; type StoreApi = ReturnType; 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 diff --git a/apps/web/src/locales/en/sidebar.json b/apps/web/src/locales/en/sidebar.json index aa70555719..84ac1e9fd0 100644 --- a/apps/web/src/locales/en/sidebar.json +++ b/apps/web/src/locales/en/sidebar.json @@ -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", diff --git a/apps/web/src/locales/pseudo/sidebar.json b/apps/web/src/locales/pseudo/sidebar.json index d9d027fad5..165edfddd1 100644 --- a/apps/web/src/locales/pseudo/sidebar.json +++ b/apps/web/src/locales/pseudo/sidebar.json @@ -40,6 +40,7 @@ "logOut": "Ĺōĝ ōũţ", "maxViews_one": "{{count}} ḿàx", "maxViews_other": "{{count}} ḿàx", + "nestUnder": "Ńēśţ ũńďēŕ {{title}}", "newKanbanWorkspace": "Ńēŵ ķàńƀàń ŵōŕķśƥàćē", "newOfficeWorkspace": "Ńēŵ ōƒƒĩćē ŵōŕķśƥàćē", "newTask": "Ńēŵ Ţàśķ", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index aecf74d526..ce612e9f32 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -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); +} diff --git a/docs/plans/subtask-reparenting-drag-drop/plan.md b/docs/plans/subtask-reparenting-drag-drop/plan.md new file mode 100644 index 0000000000..207997ae09 --- /dev/null +++ b/docs/plans/subtask-reparenting-drag-drop/plan.md @@ -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 ". 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. diff --git a/docs/plans/subtask-reparenting-drag-drop/task-01-backend-composite-semantics.md b/docs/plans/subtask-reparenting-drag-drop/task-01-backend-composite-semantics.md new file mode 100644 index 0000000000..f822bb8c32 --- /dev/null +++ b/docs/plans/subtask-reparenting-drag-drop/task-01-backend-composite-semantics.md @@ -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`. diff --git a/docs/plans/subtask-reparenting-drag-drop/task-02-frontend-nest-drop-infrastructure.md b/docs/plans/subtask-reparenting-drag-drop/task-02-frontend-nest-drop-infrastructure.md new file mode 100644 index 0000000000..1ab3f59d1e --- /dev/null +++ b/docs/plans/subtask-reparenting-drag-drop/task-02-frontend-nest-drop-infrastructure.md @@ -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`). diff --git a/docs/plans/subtask-reparenting-drag-drop/task-03-frontend-wiring-and-affordances.md b/docs/plans/subtask-reparenting-drag-drop/task-03-frontend-wiring-and-affordances.md new file mode 100644 index 0000000000..2e69010538 --- /dev/null +++ b/docs/plans/subtask-reparenting-drag-drop/task-03-frontend-wiring-and-affordances.md @@ -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`. diff --git a/docs/plans/subtask-reparenting-drag-drop/task-04-e2e-and-verification.md b/docs/plans/subtask-reparenting-drag-drop/task-04-e2e-and-verification.md new file mode 100644 index 0000000000..10dcb293c4 --- /dev/null +++ b/docs/plans/subtask-reparenting-drag-drop/task-04-e2e-and-verification.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index a7db370716..8bfdd84532 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/tasks/subtask-detachment.md b/docs/specs/tasks/subtask-detachment.md index 554ddd1275..746c6120b2 100644 --- a/docs/specs/tasks/subtask-detachment.md +++ b/docs/specs/tasks/subtask-detachment.md @@ -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. diff --git a/docs/specs/tasks/subtask-reparenting-drag-drop.md b/docs/specs/tasks/subtask-reparenting-drag-drop.md new file mode 100644 index 0000000000..ac5be01cd0 --- /dev/null +++ b/docs/specs/tasks/subtask-reparenting-drag-drop.md @@ -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).