diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index f494f167c1..b7932c6f1d 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -996,6 +996,9 @@ func wireOfficeSvcsDependencies( services.OfficeSvcs.Dashboard.SetRetryCanceller(services.Office) // Wire the office service as the task canceller for status→cancelled hard-cancels. services.OfficeSvcs.Dashboard.SetTaskCanceller(services.Office) + // Route the Office "No parent" mutation through the canonical task detach + // operation so inherited workspace sharing remains valid. + services.OfficeSvcs.Dashboard.SetTaskDetacher(services.Task) // Wire the reactivity pipeline so property mutations queue downstream runs. services.OfficeSvcs.Dashboard.SetReactivityApplier( officescheduler.NewDashboardReactivityAdapter(services.OfficeSvcs.Scheduler), diff --git a/apps/backend/internal/office/dashboard/service.go b/apps/backend/internal/office/dashboard/service.go index 384bcf9ef4..d126e18c42 100644 --- a/apps/backend/internal/office/dashboard/service.go +++ b/apps/backend/internal/office/dashboard/service.go @@ -12,6 +12,7 @@ import ( "github.com/kandev/kandev/internal/office/repository/sqlite" "github.com/kandev/kandev/internal/office/routing" "github.com/kandev/kandev/internal/office/shared" + taskmodels "github.com/kandev/kandev/internal/task/models" workflowmodels "github.com/kandev/kandev/internal/workflow/models" "go.uber.org/zap" @@ -149,6 +150,12 @@ type TaskCanceller interface { CancelTaskExecution(ctx context.Context, taskID, reason string, force bool) error } +// TaskDetacher applies the canonical task hierarchy/workspace detachment and +// publishes the task lifecycle and Office refresh events. +type TaskDetacher interface { + DetachTask(ctx context.Context, taskID string) (*taskmodels.Task, error) +} + // SessionTerminator flips the (task, agent) office session row to a terminal // state. Used when an agent stops being a participant on a task — reassignment, // reviewer/approver removal, or agent instance deletion. Idempotent: skipping @@ -331,6 +338,7 @@ type DashboardService struct { eb bus.EventBus // optional; nil means no events are published retryCanceller RetryCanceller // optional; nil means retries are not cancelled on reassign taskCanceller TaskCanceller // optional; used to hard-cancel sessions on status→cancelled + taskDetacher TaskDetacher // optional; canonical empty-parent mutation sessionTerm SessionTerminator // optional; flips office session rows to COMPLETED on participation removal reactivity ReactivityApplier // optional; runs the office reactivity pipeline on mutations engineDispatcher shared.WorkflowEngineDispatcher // optional; synchronously routes comment triggers through the engine @@ -449,6 +457,12 @@ func (s *DashboardService) SetTaskCanceller(c TaskCanceller) { s.taskCanceller = c } +// SetTaskDetacher wires the canonical task detachment operation used when the +// Office parent picker selects "No parent". +func (s *DashboardService) SetTaskDetacher(d TaskDetacher) { + s.taskDetacher = d +} + // SetSessionTerminator wires the office session terminator. Optional; when // nil, participation removal won't flip the prior agent's session to a // terminal state (the row stays IDLE/RUNNING and its conversation lingers). diff --git a/apps/backend/internal/office/dashboard/service_detachment_test.go b/apps/backend/internal/office/dashboard/service_detachment_test.go new file mode 100644 index 0000000000..9dd63b1dc9 --- /dev/null +++ b/apps/backend/internal/office/dashboard/service_detachment_test.go @@ -0,0 +1,85 @@ +package dashboard_test + +import ( + "context" + "testing" + + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +type recordingTaskDetacher struct { + taskIDs []string + err error +} + +func (d *recordingTaskDetacher) DetachTask(_ context.Context, taskID string) (*taskmodels.Task, error) { + d.taskIDs = append(d.taskIDs, taskID) + return &taskmodels.Task{ID: taskID}, d.err +} + +func TestUpdateTaskParentIDUsesCanonicalDetacherWhenCleared(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) + } + detacher := &recordingTaskDetacher{} + deps.svc.SetTaskDetacher(detacher) + + if err := deps.svc.UpdateTaskParentID(context.Background(), "child", ""); err != nil { + t.Fatalf("UpdateTaskParentID: %v", err) + } + + if len(detacher.taskIDs) != 1 || detacher.taskIDs[0] != "child" { + t.Fatalf("DetachTask calls = %#v, want [child]", detacher.taskIDs) + } + 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, direct Office update bypassed canonical detacher", parentID) + } +} + +func TestUpdateTaskParentIDFailsClosedWhenDetacherIsNotConfigured(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", ""); err == nil { + t.Fatal("UpdateTaskParentID error = nil, want missing detacher 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 parent", parentID) + } +} + +func TestUpdateTaskParentIDKeepsNonEmptyReparentingInOffice(t *testing.T) { + deps := newTestDeps(t) + insertTestTask(t, deps.db, "child", "workspace", "Child", "todo", 1) + detacher := &recordingTaskDetacher{} + deps.svc.SetTaskDetacher(detacher) + + if err := deps.svc.UpdateTaskParentID(context.Background(), "child", "new-parent"); err != nil { + t.Fatalf("UpdateTaskParentID: %v", err) + } + + if len(detacher.taskIDs) != 0 { + t.Fatalf("DetachTask calls = %#v, want none", detacher.taskIDs) + } + 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) + } +} diff --git a/apps/backend/internal/office/dashboard/service_tasks.go b/apps/backend/internal/office/dashboard/service_tasks.go index 51b03f6bed..e312d6b0dd 100644 --- a/apps/backend/internal/office/dashboard/service_tasks.go +++ b/apps/backend/internal/office/dashboard/service_tasks.go @@ -71,6 +71,15 @@ func (s *DashboardService) UpdateTaskParentID(ctx context.Context, taskID, paren if parentID != "" && parentID == taskID { return fmt.Errorf("task cannot be its own parent") } + if parentID == "" { + if s.taskDetacher == nil { + return errors.New("task detacher is not configured") + } + if _, err := s.taskDetacher.DetachTask(ctx, taskID); err != nil { + return err + } + return nil + } if err := s.repo.UpdateTaskParentID(ctx, taskID, parentID); err != nil { return err } diff --git a/apps/backend/internal/plugins/runtime/manager_test.go b/apps/backend/internal/plugins/runtime/manager_test.go index c45ee8a4b6..a40ee746fd 100644 --- a/apps/backend/internal/plugins/runtime/manager_test.go +++ b/apps/backend/internal/plugins/runtime/manager_test.go @@ -145,16 +145,20 @@ func TestManager_StartDeliverEventWebhookStop(t *testing.T) { }) t.Run("DeliverEvent reaches the plugin, which calls back into Host.SetState", func(t *testing.T) { - err := remote.DeliverEvent(ctx, &pluginsdk.Event{EventID: "e1", EventType: "task.created"}) - if err != nil { - t.Fatalf("DeliverEvent() unexpected error: %v", err) - } - - select { - case <-host.setCh: - case <-time.After(5 * time.Second): - t.Fatal("timed out waiting for the plugin's Host.SetState callback") - } + require.Eventually(t, func() bool { + if err := remote.DeliverEvent(ctx, &pluginsdk.Event{ + EventID: "e1", EventType: "task.created", + }); err != nil { + return false + } + select { + case <-host.setCh: + return true + default: + return false + } + }, 5*time.Second, 20*time.Millisecond, + "plugin should call Host.SetState after asynchronous Host injection completes") value, found := host.get("instance", "", "last_event") if !found { diff --git a/apps/backend/internal/task/handlers/task_detachment_test.go b/apps/backend/internal/task/handlers/task_detachment_test.go new file mode 100644 index 0000000000..f0491be0c9 --- /dev/null +++ b/apps/backend/internal/task/handlers/task_detachment_test.go @@ -0,0 +1,147 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/service" + "github.com/stretchr/testify/require" +) + +type detachHandlerRepo struct { + mockRepository + task *models.Task + getErr error + updated *models.Task + updateErr error +} + +func (r *detachHandlerRepo) GetTask(_ context.Context, _ string) (*models.Task, error) { + if r.getErr != nil { + return nil, r.getErr + } + copy := *r.task + return ©, nil +} + +func (r *detachHandlerRepo) UpdateTask(_ context.Context, task *models.Task) error { + if r.updateErr != nil { + return r.updateErr + } + r.updated = task + return nil +} + +func (r *detachHandlerRepo) DetachTask(_ context.Context, _ string) (bool, error) { + if r.getErr != nil { + return false, r.getErr + } + if r.updateErr != nil { + return false, r.updateErr + } + if r.task.ParentID == "" { + return false, nil + } + r.task.ParentID = "" + if workspace, ok := r.task.Metadata["workspace"].(map[string]interface{}); ok && workspace["mode"] == "inherit_parent" { + workspace["mode"] = "shared_group" + } + r.updated = r.task + return true, nil +} + +func (r *detachHandlerRepo) UpsertSessionFileReview(context.Context, *models.SessionFileReview) error { + return nil +} + +func (r *detachHandlerRepo) GetSessionFileReviews(context.Context, string) ([]*models.SessionFileReview, error) { + return nil, nil +} + +func (r *detachHandlerRepo) DeleteSessionFileReviews(context.Context, string) error { return nil } + +func (r *detachHandlerRepo) ListTurnsBySession(context.Context, string) ([]*models.Turn, error) { + return nil, nil +} + +func (r *detachHandlerRepo) CountToolCallMessagesBySession(context.Context, []string) (map[string]int, error) { + return nil, nil +} + +func testDetachRouter(t *testing.T, repo *detachHandlerRepo) *gin.Engine { + t.Helper() + gin.SetMode(gin.TestMode) + log := newTestLogger(t) + svc := service.NewService(service.Repos{ + Tasks: repo, TaskRepos: repo, Sessions: repo, Messages: repo, + }, nil, log, service.RepositoryDiscoveryConfig{}) + router := gin.New() + NewTaskHandlers(svc, nil, repo, nil, log).registerHTTP(router) + return router +} + +func TestHTTPDetachTaskReturnsUpdatedTask(t *testing.T) { + repo := &detachHandlerRepo{task: &models.Task{ + ID: "child", ParentID: "parent", Metadata: map[string]interface{}{ + "workspace": map[string]interface{}{"mode": "inherit_parent", "group_id": "group-1"}, + }, + }} + router := testDetachRouter(t, repo) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/child/detach", nil)) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + var response struct { + ID string `json:"id"` + ParentID string `json:"parent_id"` + Metadata map[string]interface{} `json:"metadata"` + } + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &response)) + require.Equal(t, "child", response.ID) + require.Empty(t, response.ParentID) + workspace := response.Metadata["workspace"].(map[string]interface{}) + require.Equal(t, "shared_group", workspace["mode"]) + require.NotNil(t, repo.updated) +} + +func TestHTTPDetachTaskReturnsNotFound(t *testing.T) { + repo := &detachHandlerRepo{getErr: repository.ErrTaskNotFound} + router := testDetachRouter(t, repo) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/missing/detach", nil)) + + require.Equal(t, http.StatusNotFound, rec.Code, rec.Body.String()) +} + +func TestHTTPDetachTaskIsIdempotentForRoot(t *testing.T) { + repo := &detachHandlerRepo{task: &models.Task{ID: "root"}} + router := testDetachRouter(t, repo) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/root/detach", nil)) + + require.Equal(t, http.StatusOK, rec.Code, rec.Body.String()) + require.Nil(t, repo.updated, "root detach must not issue a task-row update") +} + +func TestHTTPDetachTaskReturnsInternalErrorWhenPersistenceFails(t *testing.T) { + repo := &detachHandlerRepo{ + task: &models.Task{ID: "child", ParentID: "parent"}, + updateErr: errors.New("database write failed"), + } + router := testDetachRouter(t, repo) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/api/v1/tasks/child/detach", nil)) + + require.Equal(t, http.StatusInternalServerError, rec.Code, rec.Body.String()) +} diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 3d43a8d1e1..fad86b7bcc 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -123,6 +123,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.GET("/task-sessions/:id/turns", h.httpListSessionTurns) api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) + api.POST("/tasks/:id/detach", h.httpDetachTask) api.PATCH("/tasks/:id/repositories/:repo_id", h.httpUpdateTaskRepository) api.POST("/tasks/:id/move", h.httpMoveTask) api.DELETE("/tasks/:id", h.httpDeleteTask) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 487e9b6cf4..740371dff3 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -1051,6 +1051,15 @@ func (h *TaskHandlers) httpUpdateTask(c *gin.Context) { c.JSON(http.StatusOK, dto.FromTask(task)) } +func (h *TaskHandlers) httpDetachTask(c *gin.Context) { + task, err := h.service.DetachTask(c.Request.Context(), c.Param("id")) + if err != nil { + handleNotFound(c, h.logger, err, "task not found") + return + } + c.JSON(http.StatusOK, dto.FromTask(task)) +} + type httpUpdateTaskRepositoryRequest struct { BaseBranch string `json:"base_branch"` } diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 7521b8b25b..204a1c538d 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -287,6 +287,71 @@ func (r *Repository) UpdateTask(ctx context.Context, task *models.Task) error { return tx.Commit() } +// DetachTask clears only the hierarchy fields involved in detachment. Keeping +// this as a targeted update prevents concurrent task edits from being replaced +// by a stale full-row write. +func (r *Repository) DetachTask(ctx context.Context, taskID string) (bool, error) { + result, err := r.db.ExecContext( + ctx, + r.db.Rebind(detachTaskQuery(r.db.DriverName())), + time.Now().UTC(), + taskID, + ) + if err != nil { + return false, err + } + + rows, err := result.RowsAffected() + if err != nil { + return false, err + } + if rows > 0 { + return true, nil + } + + var existingID string + if err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT id FROM tasks WHERE id = ?`), taskID).Scan(&existingID); err != nil { + if err == sql.ErrNoRows { + return false, fmt.Errorf("%w: %s", ErrTaskNotFound, taskID) + } + return false, err + } + return false, nil +} + +func detachTaskQuery(driver string) string { + if dialect.IsPostgres(driver) { + return ` + UPDATE tasks + SET parent_id = '', + metadata = CASE + WHEN jsonb_extract_path_text( + CASE WHEN metadata IS NULL OR metadata = '' THEN '{}'::jsonb ELSE metadata::jsonb END, + 'workspace', 'mode' + ) = 'inherit_parent' + THEN jsonb_set(metadata::jsonb, '{workspace,mode}', '"shared_group"'::jsonb, true)::text + ELSE metadata + END, + updated_at = ? + WHERE id = ? AND parent_id != '' + ` + } + return ` + UPDATE tasks + SET parent_id = '', + metadata = CASE + WHEN 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 = ? + WHERE id = ? AND parent_id != '' + ` +} + // UpdateTaskIfWorkflowStepHasCapacity updates a task inside the same write // transaction that checks a WIP-limited target step's current occupancy. func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, task *models.Task, targetStepID, excludeTaskID string, limit int) error { diff --git a/apps/backend/internal/task/repository/sqlite/task_order_test.go b/apps/backend/internal/task/repository/sqlite/task_order_test.go index 74b806084b..7a7ce716fa 100644 --- a/apps/backend/internal/task/repository/sqlite/task_order_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_order_test.go @@ -43,3 +43,18 @@ func TestTaskSearchSelectQuery_OrdersOutsideDistinctForPostgres(t *testing.T) { t.Fatalf("query = %q, want outer Postgres title ordering", query) } } + +func TestDetachTaskQueryUsesDialectJSONFunctions(t *testing.T) { + sqliteQuery := detachTaskQuery(dialect.SQLite3) + if !strings.Contains(sqliteQuery, "json_set") || strings.Contains(sqliteQuery, "jsonb_set") { + t.Fatalf("SQLite detach query uses wrong JSON functions: %s", sqliteQuery) + } + + postgresQuery := detachTaskQuery(dialect.PGX) + if !strings.Contains(postgresQuery, "jsonb_set") || !strings.Contains(postgresQuery, "jsonb_extract_path_text") { + t.Fatalf("Postgres detach query must use JSONB functions: %s", postgresQuery) + } + if strings.Contains(postgresQuery, "json_extract(") || strings.Contains(postgresQuery, "json_set(") { + t.Fatalf("Postgres detach query uses SQLite JSON functions: %s", postgresQuery) + } +} diff --git a/apps/backend/internal/task/service/service_detachment.go b/apps/backend/internal/task/service/service_detachment.go new file mode 100644 index 0000000000..cd48a69d80 --- /dev/null +++ b/apps/backend/internal/task/service/service_detachment.go @@ -0,0 +1,58 @@ +package service + +import ( + "context" + "errors" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" + "github.com/kandev/kandev/internal/task/models" + + "go.uber.org/zap" +) + +type taskDetachmentRepository interface { + DetachTask(ctx context.Context, taskID string) (bool, error) +} + +// DetachTask promotes a task to a root while preserving its existing shared +// workspace. Repeated calls for an already-root task are successful no-ops. +func (s *Service) DetachTask(ctx context.Context, id string) (*models.Task, error) { + repo, ok := s.tasks.(taskDetachmentRepository) + if !ok { + return nil, errors.New("task repository does not support detachment") + } + if _, err := repo.DetachTask(ctx, id); err != nil { + s.logger.Error("failed to detach task", zap.String("task_id", id), zap.Error(err)) + return nil, err + } + + task, err := s.GetTask(ctx, id) + if err != nil { + return nil, err + } + + var extra map[string]interface{} + if task.ParentID == "" { + extra = map[string]interface{}{"parent_id": nil} + } + s.publishTaskEventWithExtra(ctx, events.TaskUpdated, task, nil, extra) + s.publishDetachOfficeEvent(ctx, task) + return task, nil +} + +func (s *Service) publishDetachOfficeEvent(ctx context.Context, task *models.Task) { + if s.eventBus == nil { + return + } + data := map[string]interface{}{ + "task_id": task.ID, + "workspace_id": task.WorkspaceID, + "fields": []string{"parent_id", "metadata"}, + } + event := bus.NewEvent(events.OfficeTaskUpdated, "task-service", data) + if err := s.eventBus.Publish(ctx, events.OfficeTaskUpdated, event); err != nil { + s.logger.Error("failed to publish Office task detach event", + zap.String("task_id", task.ID), zap.Error(err)) + } +} diff --git a/apps/backend/internal/task/service/service_detachment_test.go b/apps/backend/internal/task/service/service_detachment_test.go new file mode 100644 index 0000000000..e6aeecf3f9 --- /dev/null +++ b/apps/backend/internal/task/service/service_detachment_test.go @@ -0,0 +1,300 @@ +package service + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type reparentAfterDetachRepository struct { + repository.TaskRepository + detacher taskDetachmentRepository + parentID string +} + +func (r *reparentAfterDetachRepository) DetachTask(ctx context.Context, taskID string) (bool, error) { + changed, err := r.detacher.DetachTask(ctx, taskID) + if err != nil { + return false, err + } + task, err := r.GetTask(ctx, taskID) + if err != nil { + return false, err + } + task.ParentID = r.parentID + if err := r.UpdateTask(ctx, task); err != nil { + return false, err + } + return changed, nil +} + +func TestDetachTaskNormalizesInheritedWorkspaceAndPreservesTaskState(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + before, err := repo.GetTask(ctx, "child") + if err != nil { + t.Fatalf("GetTask before detach: %v", err) + } + eventBus.ClearEvents() + + detached, err := svc.DetachTask(ctx, "child") + if err != nil { + t.Fatalf("DetachTask: %v", err) + } + + if detached.ParentID != "" { + t.Fatalf("ParentID = %q, want empty", detached.ParentID) + } + if detached.WorkflowID != "workflow" || detached.WorkflowStepID != "step" || detached.State != v1.TaskStateInProgress { + t.Fatalf("placement/state changed: %#v", detached) + } + if !detached.UpdatedAt.After(before.UpdatedAt) { + t.Fatalf("UpdatedAt = %s, want after %s", detached.UpdatedAt, before.UpdatedAt) + } + workspace, ok := detached.Metadata["workspace"].(map[string]interface{}) + if !ok { + t.Fatalf("workspace metadata = %#v", detached.Metadata["workspace"]) + } + if workspace["mode"] != "shared_group" { + t.Fatalf("workspace mode = %#v, want shared_group", workspace["mode"]) + } + if workspace["group_id"] != "group-1" || detached.Metadata["unrelated"] != "keep" { + t.Fatalf("unrelated metadata changed: %#v", detached.Metadata) + } + + persisted, err := repo.GetTask(ctx, "child") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if persisted.ParentID != "" { + t.Fatalf("persisted ParentID = %q, want empty", persisted.ParentID) + } + persistedWorkspace := persisted.Metadata["workspace"].(map[string]interface{}) + if persistedWorkspace["mode"] != "shared_group" { + t.Fatalf("persisted workspace mode = %#v", persistedWorkspace["mode"]) + } + + eventData := singleDetachmentEventData(t, eventBus) + if parentID, ok := eventData["parent_id"]; !ok || parentID != nil { + t.Fatalf("parent_id event field = %#v (present=%v), want explicit nil", parentID, ok) + } + eventMetadata := eventData["metadata"].(map[string]interface{}) + eventWorkspace := eventMetadata["workspace"].(map[string]interface{}) + if eventWorkspace["mode"] != "shared_group" { + t.Fatalf("event workspace mode = %#v, want shared_group", eventWorkspace["mode"]) + } +} + +func TestDetachTaskIsIdempotentForRootTask(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + if _, err := svc.DetachTask(ctx, "child"); err != nil { + t.Fatalf("first DetachTask: %v", err) + } + before, err := repo.GetTask(ctx, "child") + if err != nil { + t.Fatalf("GetTask before retry: %v", err) + } + eventBus.ClearEvents() + + detached, err := svc.DetachTask(ctx, "child") + if err != nil { + t.Fatalf("second DetachTask: %v", err) + } + + if !detached.UpdatedAt.Equal(before.UpdatedAt) { + t.Fatalf("UpdatedAt changed on idempotent retry: %s -> %s", before.UpdatedAt, detached.UpdatedAt) + } + eventData := singleDetachmentEventData(t, eventBus) + if parentID, ok := eventData["parent_id"]; !ok || parentID != nil { + t.Fatalf("parent_id event field = %#v (present=%v), want explicit nil", parentID, ok) + } +} + +func TestDetachTaskPreservesNonInheritedWorkspaceModes(t *testing.T) { + for _, mode := range []string{"shared_group", "new_workspace"} { + t.Run(mode, func(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + task, err := repo.GetTask(ctx, "child") + if err != nil { + t.Fatalf("GetTask: %v", err) + } + workspace := task.Metadata["workspace"].(map[string]interface{}) + workspace["mode"] = mode + if err := repo.UpdateTask(ctx, task); err != nil { + t.Fatalf("UpdateTask fixture: %v", err) + } + + detached, err := svc.DetachTask(ctx, "child") + if err != nil { + t.Fatalf("DetachTask: %v", err) + } + + detachedWorkspace := detached.Metadata["workspace"].(map[string]interface{}) + if detachedWorkspace["mode"] != mode { + t.Fatalf("workspace mode = %#v, want %s", detachedWorkspace["mode"], mode) + } + if detachedWorkspace["group_id"] != "group-1" { + t.Fatalf("workspace group_id = %#v, want group-1", detachedWorkspace["group_id"]) + } + }) + } +} + +func TestDetachTaskPreservesDescendantsAndWorkspaceGroupMembership(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + if err := repo.CreateTask(ctx, &models.Task{ + ID: "grandchild", WorkspaceID: "workspace", WorkflowID: "workflow", WorkflowStepID: "step", + Title: "Grandchild", Priority: "medium", State: v1.TaskStateTODO, ParentID: "child", + }); err != nil { + t.Fatalf("CreateTask grandchild: %v", err) + } + now := time.Now().UTC() + if _, err := repo.DB().ExecContext(ctx, ` + INSERT INTO task_workspace_groups + (id, workspace_id, owner_task_id, materialized_kind, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?) + `, "group-1", "workspace", "parent", "single_repo", now, now); err != nil { + t.Fatalf("insert workspace group: %v", err) + } + if _, err := repo.DB().ExecContext(ctx, ` + INSERT INTO task_workspace_group_members (workspace_group_id, task_id, role, created_at) + VALUES (?, ?, ?, ?) + `, "group-1", "child", "member", now); err != nil { + t.Fatalf("insert workspace group member: %v", err) + } + + if _, err := svc.DetachTask(ctx, "child"); err != nil { + t.Fatalf("DetachTask: %v", err) + } + + grandchild, err := repo.GetTask(ctx, "grandchild") + if err != nil { + t.Fatalf("GetTask grandchild: %v", err) + } + if grandchild.ParentID != "child" { + t.Fatalf("grandchild ParentID = %q, want child", grandchild.ParentID) + } + var releasedAt *time.Time + if err := repo.DB().QueryRowContext(ctx, ` + SELECT released_at FROM task_workspace_group_members + WHERE workspace_group_id = ? AND task_id = ? + `, "group-1", "child").Scan(&releasedAt); err != nil { + t.Fatalf("query workspace group member: %v", err) + } + if releasedAt != nil { + t.Fatalf("workspace group membership released at %s", releasedAt) + } +} + +func TestDetachTaskEventReflectsConcurrentReparent(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + eventBus.ClearEvents() + svc.tasks = &reparentAfterDetachRepository{ + TaskRepository: repo, + detacher: repo, + parentID: "replacement-parent", + } + + task, err := svc.DetachTask(ctx, "child") + if err != nil { + t.Fatalf("DetachTask: %v", err) + } + if task.ParentID != "replacement-parent" { + t.Fatalf("ParentID = %q, want replacement-parent", task.ParentID) + } + eventData := singleDetachmentEventData(t, eventBus) + if eventData["parent_id"] != "replacement-parent" { + t.Fatalf("parent_id event field = %#v, want replacement-parent", eventData["parent_id"]) + } +} + +func TestDetachTaskPublishesOfficeTaskUpdated(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + createDetachmentFixture(t, ctx, repo) + eventBus.ClearEvents() + + if _, err := svc.DetachTask(ctx, "child"); err != nil { + t.Fatalf("DetachTask: %v", err) + } + + for _, event := range eventBus.GetPublishedEvents() { + if event.Type != events.OfficeTaskUpdated { + continue + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("office event data type = %T, want map", event.Data) + } + if data["task_id"] != "child" || data["workspace_id"] != "workspace" { + t.Fatalf("office event identity = %#v", data) + } + fields, ok := data["fields"].([]string) + if !ok || len(fields) != 2 || fields[0] != "parent_id" || fields[1] != "metadata" { + t.Fatalf("office event fields = %#v, want parent_id and metadata", data["fields"]) + } + return + } + t.Fatal("office.task.updated event was not published") +} + +func createDetachmentFixture(t *testing.T, ctx context.Context, repo taskEventTestRepository) { + t.Helper() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "workspace", Name: "Workspace"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "workflow", WorkspaceID: "workspace", Name: "Workflow"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "parent", WorkspaceID: "workspace", WorkflowID: "workflow", WorkflowStepID: "step", + Title: "Parent", Priority: "medium", State: v1.TaskStateTODO, + }); err != nil { + t.Fatalf("CreateTask parent: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "child", WorkspaceID: "workspace", WorkflowID: "workflow", WorkflowStepID: "step", + Title: "Child", Priority: "high", State: v1.TaskStateInProgress, ParentID: "parent", + Metadata: map[string]interface{}{ + "workspace": map[string]interface{}{ + "mode": "inherit_parent", + "group_id": "group-1", + }, + "unrelated": "keep", + }, + CreatedAt: time.Now().UTC(), + }); err != nil { + t.Fatalf("CreateTask child: %v", err) + } +} + +func singleDetachmentEventData(t *testing.T, eventBus *MockEventBus) map[string]interface{} { + t.Helper() + published := eventBus.GetPublishedEvents() + for _, event := range published { + if event.Type != events.TaskUpdated { + continue + } + data, ok := event.Data.(map[string]interface{}) + if !ok { + t.Fatalf("event data type = %T, want map", event.Data) + } + return data + } + t.Fatalf("published events = %#v, want task.updated", published) + return nil +} diff --git a/apps/web/app/office/tasks/[id]/types.ts b/apps/web/app/office/tasks/[id]/types.ts index 97cb042751..b4f888215a 100644 --- a/apps/web/app/office/tasks/[id]/types.ts +++ b/apps/web/app/office/tasks/[id]/types.ts @@ -136,6 +136,7 @@ export type Task = { projectName?: string; projectColor?: string; parentId?: string; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; parentTitle?: string; parentIdentifier?: string; blockedBy: string[]; diff --git a/apps/web/components/kanban-card-menu-items.test.tsx b/apps/web/components/kanban-card-menu-items.test.tsx index 61084d9408..c62e5591ed 100644 --- a/apps/web/components/kanban-card-menu-items.test.tsx +++ b/apps/web/components/kanban-card-menu-items.test.tsx @@ -130,3 +130,33 @@ describe("buildKanbanCardMenuEntries — external issue links", () => { expect(itemLabels(linkMenu)).toEqual(["GitHub Pull Request", "GitHub Issue", "Jira Ticket"]); }); }); + +describe("buildKanbanCardMenuEntries — detach", () => { + const baseArgs = { + workflows: [], + stepsByWorkflowId: {}, + }; + + it("offers detach for a child task and invokes the action", () => { + const onDetach = vi.fn(); + const entries = buildKanbanCardMenuEntries({ + ...baseArgs, + parentTaskId: "parent-1", + onDetach, + }); + const detach = entries.find((entry) => entry.kind === "item" && entry.key === "detach"); + + expect(detach?.kind).toBe("item"); + if (detach?.kind === "item") detach.onSelect?.(); + expect(onDetach).toHaveBeenCalledOnce(); + }); + + it("omits detach for a root task", () => { + const entries = buildKanbanCardMenuEntries({ + ...baseArgs, + onDetach: vi.fn(), + }); + + expect(entries.some((entry) => entry.key === "detach")).toBe(false); + }); +}); diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx index 4afa98a50b..058316904e 100644 --- a/apps/web/components/kanban-card-menu-items.tsx +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -13,6 +13,7 @@ import { IconPencil, IconTicket, IconTrash, + IconUnlink, } from "@tabler/icons-react"; import { ContextMenuItem, @@ -80,9 +81,12 @@ type BuildKanbanCardMenuEntriesArgs = { disabled?: boolean; isDeleting?: boolean; isArchiving?: boolean; + isDetaching?: boolean; + parentTaskId?: string | null; onEdit?: () => void; onArchive?: () => void; onDelete?: () => void; + onDetach?: () => void; onLinkPullRequest?: () => void; onLinkIssue?: () => void; onLinkJiraTicket?: () => void; @@ -323,9 +327,12 @@ export function buildKanbanCardMenuEntries({ disabled, isDeleting, isArchiving, + isDetaching, + parentTaskId, onEdit, onArchive, onDelete, + onDetach, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -336,7 +343,7 @@ export function buildKanbanCardMenuEntries({ }: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] { const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden); const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : []; - const isProcessing = Boolean(disabled || isDeleting || isArchiving); + const isProcessing = Boolean(disabled || isDeleting || isArchiving || isDetaching); const entries: KanbanCardMenuEntry[] = [ { kind: "item", @@ -388,6 +395,9 @@ export function buildKanbanCardMenuEntries({ onSelect: onArchive, }); + const detachEntry = buildDetachEntry({ parentTaskId, onDetach, isDetaching, isProcessing }); + if (detachEntry) entries.push(detachEntry); + entries.push({ kind: "separator", key: "delete-separator" }); entries.push({ kind: "item", @@ -406,6 +416,30 @@ export function buildKanbanCardMenuEntries({ return entries; } +function buildDetachEntry({ + parentTaskId, + onDetach, + isDetaching, + isProcessing, +}: Pick & { + isProcessing: boolean; +}): KanbanCardMenuEntry | null { + if (!parentTaskId || !onDetach) return null; + return { + kind: "item", + key: "detach", + testId: "task-context-detach", + icon: isDetaching ? ( + + ) : ( + + ), + label: "Detach from parent", + disabled: isProcessing, + onSelect: onDetach, + }; +} + export function useKanbanCardMoveTargets( taskId: string, steps?: WorkflowStep[], diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 09ebc74e45..87fd4c257f 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -11,6 +11,7 @@ import { import { useAppStore } from "@/components/state-provider"; import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { TaskDetachConfirmDialog } from "@/components/task/task-detach-confirm-dialog"; import { TaskExternalLinkDialog, type ExternalLinkProvider, @@ -20,6 +21,7 @@ import { TaskGitHubIssueDialog } from "@/components/task/task-github-issue-dialo import { TaskGitHubPRDialog } from "@/components/task/task-github-pr-dialog"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { useTaskMultiSelectStore } from "@/hooks/use-task-multi-select"; +import { useDetachTask } from "@/hooks/use-detach-task"; import { repositorySlug } from "@/lib/repository-slug"; import { formatUserHomePath } from "@/lib/utils"; import { @@ -54,6 +56,7 @@ export interface Task { primaryExecutorName?: string | null; isRemoteExecutor?: boolean; parentTaskId?: string | null; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; createdAt?: string; issueUrl?: string; @@ -198,12 +201,25 @@ function useKanbanCardMenus({ const moveMenu = useKanbanCardMoveMenuActions({ task, steps, isSelected, selectedIds, onMove }); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const [showDetachConfirm, setShowDetachConfirm] = useState(false); const [showPRDialog, setShowPRDialog] = useState(false); const [showIssueDialog, setShowIssueDialog] = useState(false); const [externalLinkProvider, setExternalLinkProvider] = useState( null, ); - const disabled = Boolean(isDeleting || isArchiving); + const { detachTask, detachingTaskId } = useDetachTask(); + const isDetaching = detachingTaskId === task.id; + const disabled = Boolean(isDeleting || isArchiving || isDetaching); + const actingOnMultiSelection = Boolean(isSelected && selectedIds && selectedIds.size > 1); + + const handleDetachConfirm = async () => { + try { + await detachTask(task.id); + setShowDetachConfirm(false); + } catch (error) { + console.error("Failed to detach task:", error); + } + }; const menuBase = { currentWorkflowId: moveMenu.moveTargets.currentWorkflowId, @@ -213,9 +229,13 @@ function useKanbanCardMenus({ disabled, isDeleting, isArchiving, + isDetaching, + parentTaskId: task.parentTaskId, onEdit: onEdit ? () => onEdit(task) : undefined, onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined, onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined, + onDetach: + task.parentTaskId && !actingOnMultiSelection ? () => setShowDetachConfirm(true) : undefined, onLinkPullRequest: () => setShowPRDialog(true), onLinkIssue: () => setShowIssueDialog(true), ...externalLinkHandlers(externalLinkAvailability, setExternalLinkProvider), @@ -236,6 +256,10 @@ function useKanbanCardMenus({ setShowDeleteConfirm, showArchiveConfirm, setShowArchiveConfirm, + showDetachConfirm, + setShowDetachConfirm, + isDetaching, + handleDetachConfirm, showPRDialog, setShowPRDialog, showIssueDialog, @@ -286,6 +310,14 @@ function KanbanCardDialogs({ isArchiving={isArchiving} onConfirm={({ cascade }) => onArchive?.(task, { cascade })} /> + { @@ -646,5 +650,6 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open: isArchiving, handleArchiveConfirm, ...deleteActions, + ...detachActions, }; } diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx index dafd1389d8..caa21aaa3e 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen } from "@testing-library/react"; +import { fireEvent, render, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { ToastProvider } from "@/components/toast-provider"; @@ -76,6 +76,7 @@ describe("MobileTaskList", () => { onSelectTask={vi.fn()} onArchiveTask={vi.fn()} onDeleteTask={vi.fn()} + onDetachTask={vi.fn()} deletingTaskId={null} /> , @@ -89,4 +90,33 @@ describe("MobileTaskList", () => { expect(mocks.toggleSidebarGroupCollapsed).toHaveBeenCalledWith("default", "step-1"); }); + + it("opens detach from the touch task actions button for a subtask", () => { + const onDetachTask = vi.fn(); + render( + + + , + ); + + const childRow = screen + .getByText("Task child") + .closest("[data-testid='sidebar-task-item']"); + expect(childRow).not.toBeNull(); + fireEvent.click(within(childRow!).getByRole("button", { name: "Task actions" })); + fireEvent.click(screen.getByRole("menuitem", { name: "Detach from parent" })); + + expect(onDetachTask).toHaveBeenCalledWith("child"); + }); }); 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 1baf6d3890..4f9e6efec9 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -19,6 +19,7 @@ import { WorkspaceSwitcher } from "../workspace-switcher"; import { TaskCreateDialog } from "@/components/task-create-dialog"; import { TaskArchiveConfirmDialog } from "../task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "../task-delete-confirm-dialog"; +import { TaskDetachTargetConfirmDialog } from "../task-detach-confirm-dialog"; import { TaskRenameDialog } from "../task-rename-dialog"; import { SidebarLinkDialogs } from "../task-session-sidebar-dialogs"; import { useSidebarLinkActions } from "../task-session-sidebar-link-actions"; @@ -58,6 +59,7 @@ export function MobileTaskList({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -75,6 +77,7 @@ export function MobileTaskList({ onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask: (taskId: string) => void; onDeleteTask: (taskId: string) => Promise | void; + onDetachTask: (taskId: string) => Promise | void; onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; onLinkIssue?: (taskId: string, taskTitle?: string) => void; onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; @@ -123,6 +126,7 @@ export function MobileTaskList({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} onLinkJiraTicket={onLinkJiraTicket} @@ -274,6 +278,7 @@ function TaskSwitcherSurfaceContent({ onRenameTask={surfaceAction(presentation, onOpenChange, rename.handleRenameTask)} onArchiveTask={surfaceAction(presentation, onOpenChange, actions.handleArchiveTask)} onDeleteTask={surfaceAction(presentation, onOpenChange, actions.handleDeleteTask)} + onDetachTask={surfaceAction(presentation, onOpenChange, actions.handleDetachTask)} onLinkPullRequest={surfaceAction( presentation, onOpenChange, @@ -368,6 +373,12 @@ function TaskSwitcherDialogs({ isDeleting={actions.isDeleting} onConfirm={({ cascade }) => actions.handleDeleteConfirm({ cascade })} /> + actions.setDetachingTask(null)} + onConfirm={actions.handleDetachConfirm} + /> vi.fn().mockResolvedValue({ id: "child" })); +const fetchTaskMock = vi.hoisted(() => + vi.fn().mockResolvedValue({ + id: "child", + metadata: { workspace: { mode: "inherit_parent", group_id: "group-1" } }, + }), +); +const updateTaskMock = vi.hoisted(() => vi.fn().mockResolvedValue({ ok: true })); + +vi.mock("@/lib/api/domains/kanban-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/kanban-api", + ); + return { ...actual, detachTask: detachTaskMock, fetchTask: fetchTaskMock }; +}); + +vi.mock("@/lib/api/domains/office-extended-api", async () => { + const actual = await vi.importActual( + "@/lib/api/domains/office-extended-api", + ); + return { + ...actual, + updateTask: updateTaskMock, + }; +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +const task: Task = { + id: "child", + workspaceId: "ws-1", + identifier: "TASK-2", + title: "Child task", + status: "todo", + priority: "medium", + parentId: "parent-1", + parentTitle: "Parent one", + parentIdentifier: "TASK-1", + labels: [], + blockedBy: [], + blocking: [], + children: [], + reviewers: [], + approvers: [], + decisions: [], + createdBy: "user", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", +}; + +const candidates = [ + { + id: "parent-1", + workspaceId: "ws-1", + identifier: "TASK-1", + title: "Parent one", + }, + { + id: "parent-2", + workspaceId: "ws-1", + identifier: "TASK-3", + title: "Parent two", + }, +] as OfficeTask[]; + +function SeedTasks() { + const setTasks = useAppStore((state) => state.setTasks); + useEffect(() => setTasks(candidates), [setTasks]); + return null; +} + +function Wrapper({ children }: { children: ReactNode }) { + return ( + + + + {children} + + + ); +} + +describe("ParentPicker", () => { + it("uses canonical detach after confirming No parent", async () => { + render( + + + , + ); + + await waitFor(() => expect(fetchTaskMock).toHaveBeenCalledWith(task.id)); + + fireEvent.click(screen.getByTestId("parent-picker-trigger")); + fireEvent.click(await screen.findByText("No parent")); + const dialog = await screen.findByRole("alertdialog", { name: "Detach task from parent?" }); + expect(dialog.textContent).toContain("shares its parent's workspace"); + await act(async () => { + fireEvent.click(screen.getByRole("button", { name: "Detach" })); + }); + + expect(detachTaskMock).toHaveBeenCalledWith(task.id); + expect(updateTaskMock).not.toHaveBeenCalled(); + }); + + it("keeps non-empty reparenting on the Office update endpoint", async () => { + render( + + + , + ); + + fireEvent.click(screen.getByTestId("parent-picker-trigger")); + await act(async () => { + fireEvent.click(await screen.findByText("Parent two")); + }); + + expect(updateTaskMock).toHaveBeenCalledWith(task.id, { parent_id: "parent-2" }); + expect(detachTaskMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/task/simple/components/parent-picker.tsx b/apps/web/components/task/simple/components/parent-picker.tsx index 5dc424231b..ed7e6acf6b 100644 --- a/apps/web/components/task/simple/components/parent-picker.tsx +++ b/apps/web/components/task/simple/components/parent-picker.tsx @@ -4,9 +4,12 @@ import { useEffect, useMemo, useState } from "react"; import { Combobox, type ComboboxOption } from "@/components/combobox"; import { useAppStore } from "@/components/state-provider"; import { searchTasks, updateTask } from "@/lib/api/domains/office-extended-api"; +import { detachTask, fetchTask } from "@/lib/api/domains/kanban-api"; import { useOptimisticTaskMutation } from "@/hooks/use-optimistic-task-mutation"; +import { TaskDetachConfirmDialog } from "@/components/task/task-detach-confirm-dialog"; import type { OfficeTask } from "@/lib/state/slices/office/types"; import type { Task } from "@/app/office/tasks/[id]/types"; +import { workspaceModeFromMetadata, type WorkspaceMode } from "@/lib/kanban/map-task"; type ParentPickerProps = { task: Task; @@ -14,6 +17,31 @@ type ParentPickerProps = { const NO_PARENT = "__none__"; +function useTaskWorkspaceMode(task: Task) { + const [workspaceMode, setWorkspaceMode] = useState(task.workspaceMode); + + useEffect(() => { + if (task.workspaceMode) { + setWorkspaceMode(task.workspaceMode); + return; + } + if (!task.parentId) return; + let cancelled = false; + fetchTask(task.id) + .then((canonicalTask) => { + if (!cancelled) setWorkspaceMode(workspaceModeFromMetadata(canonicalTask.metadata)); + }) + .catch(() => { + if (!cancelled) setWorkspaceMode(undefined); + }); + return () => { + cancelled = true; + }; + }, [task.id, task.parentId, task.workspaceMode]); + + return workspaceMode; +} + function buildOptions(candidates: OfficeTask[], currentTaskId: string): ComboboxOption[] { const noOpt: ComboboxOption = { value: NO_PARENT, @@ -41,6 +69,9 @@ export function ParentPicker({ task }: ParentPickerProps) { const storeTasks = useAppStore((s) => s.office.tasks.items); const workspaceId = useAppStore((s) => s.workspaces.activeId); const [fetched, setFetched] = useState([]); + const [detachRequested, setDetachRequested] = useState(false); + const [isDetaching, setIsDetaching] = useState(false); + const workspaceMode = useTaskWorkspaceMode(task); const mutate = useOptimisticTaskMutation(); // If the store doesn't already have tasks for the workspace, lazily fetch. @@ -68,6 +99,10 @@ export function ParentPicker({ task }: ParentPickerProps) { const handleSelect = async (next: string) => { const sendValue = next === NO_PARENT || next === "" ? "" : next; if (sendValue === (task.parentId ?? "")) return; + if (!sendValue) { + setDetachRequested(true); + return; + } const matched = candidates.find((t) => t.id === sendValue); try { await mutate( @@ -84,17 +119,49 @@ export function ParentPicker({ task }: ParentPickerProps) { } }; + const handleDetachConfirm = async () => { + if (isDetaching) return; + setIsDetaching(true); + try { + await mutate( + task.id, + { + parentId: undefined, + parentTitle: undefined, + parentIdentifier: undefined, + }, + () => detachTask(task.id), + ); + setDetachRequested(false); + } catch { + // useOptimisticTaskMutation restores state and reports the request error. + } finally { + setIsDetaching(false); + } + }; + return ( - + <> + + + ); } diff --git a/apps/web/components/task/task-detach-confirm-dialog.tsx b/apps/web/components/task/task-detach-confirm-dialog.tsx new file mode 100644 index 0000000000..5d925db690 --- /dev/null +++ b/apps/web/components/task/task-detach-confirm-dialog.tsx @@ -0,0 +1,110 @@ +"use client"; + +import { IconLoader } from "@tabler/icons-react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@kandev/ui/alert-dialog"; + +type TaskDetachConfirmDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + taskTitle?: string; + sharesParentWorkspace?: boolean; + isDetaching?: boolean; + onConfirm: () => void; +}; + +export function TaskDetachConfirmDialog({ + open, + onOpenChange, + taskTitle, + sharesParentWorkspace, + isDetaching, + onConfirm, +}: TaskDetachConfirmDialogProps) { + return ( + { + if (!isDetaching) onOpenChange(next); + }} + > + event.stopPropagation()}> + + Detach task from parent? + +
+

+ "{taskTitle || "This task"}" will become a top-level task. Its workflow, + subtasks, and state will not change. +

+

+ Detaching changes the hierarchy only. Access to any shared workspace remains + unchanged. +

+ {sharesParentWorkspace && ( +

+ This task shares its parent's workspace. Current and future sessions will + keep using that shared workspace. +

+ )} +
+
+
+ + + Cancel + + { + event.preventDefault(); + if (!isDetaching) onConfirm(); + }} + > + {isDetaching && } + Detach + + +
+
+ ); +} + +export function TaskDetachTargetConfirmDialog({ + target, + detachingTaskId, + onDismiss, + onConfirm, +}: { + target: { + id: string; + title: string; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; + } | null; + detachingTaskId: string | null; + onDismiss: () => void; + onConfirm: () => void; +}) { + return ( + { + if (!open) onDismiss(); + }} + taskTitle={target?.title} + sharesParentWorkspace={target?.workspaceMode === "inherit_parent"} + isDetaching={target?.id === detachingTaskId} + onConfirm={onConfirm} + /> + ); +} diff --git a/apps/web/components/task/task-session-sidebar-dialogs.tsx b/apps/web/components/task/task-session-sidebar-dialogs.tsx index 3ea6c06b79..0b046b1ad4 100644 --- a/apps/web/components/task/task-session-sidebar-dialogs.tsx +++ b/apps/web/components/task/task-session-sidebar-dialogs.tsx @@ -3,6 +3,7 @@ import { TaskRenameDialog } from "./task-rename-dialog"; import { TaskArchiveConfirmDialog } from "./task-archive-confirm-dialog"; import { TaskDeleteConfirmDialog } from "./task-delete-confirm-dialog"; +import { TaskDetachTargetConfirmDialog } from "./task-detach-confirm-dialog"; import { TaskExternalLinkDialog } from "./task-external-link-dialog"; import { TaskGitHubIssueDialog } from "./task-github-issue-dialog"; import { TaskGitHubPRDialog } from "./task-github-pr-dialog"; @@ -13,6 +14,11 @@ import type { } from "./task-session-sidebar-link-actions"; type Target = { id: string; title: string; executorType?: string | null } | null; +type DetachTarget = { + id: string; + title: string; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; +} | null; type LinkTarget = SidebarLinkTarget | null; export type SidebarDialogsActions = { @@ -28,6 +34,10 @@ export type SidebarDialogsActions = { setDeletingTask: (next: Target) => void; isDeleting: boolean; handleDeleteConfirm: (opts: { cascade: boolean }) => Promise | void; + detachingTask: DetachTarget; + setDetachingTask: (next: DetachTarget) => void; + detachingTaskId: string | null; + handleDetachConfirm: () => Promise | void; linkingPullRequestTask: LinkTarget; setLinkingPullRequestTask: (next: LinkTarget) => void; linkingIssueTask: LinkTarget; @@ -58,6 +68,10 @@ export function SidebarDialogs({ setDeletingTask, isDeleting, handleDeleteConfirm, + detachingTask, + setDetachingTask, + detachingTaskId, + handleDetachConfirm, } = actions; return ( <> @@ -91,6 +105,12 @@ export function SidebarDialogs({ isDeleting={isDeleting} onConfirm={handleDeleteConfirm} /> + setDetachingTask(null)} + onConfirm={handleDetachConfirm} + /> ); 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 b41c60f586..e934c17800 100644 --- a/apps/web/components/task/task-session-sidebar-switcher-props.ts +++ b/apps/web/components/task/task-session-sidebar-switcher-props.ts @@ -45,6 +45,7 @@ export function buildTaskSwitcherProps(args: { onRenameTask: args.sidebarActions.handleRenameTask, onArchiveTask: args.sidebarActions.handleArchiveTask, onDeleteTask: args.sidebarActions.handleDeleteTask, + onDetachTask: args.sidebarActions.handleDetachTask, ...args.taskLinkHandlers, onMoveToStep: args.sidebarActions.handleMoveToStep, onTogglePin: args.togglePinnedTask, diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 76a240f72c..0b8b3ec2de 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -17,6 +17,7 @@ import { PanelRoot, PanelBody } from "./panel-primitives"; 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 { useSidebarSelection, SidebarBulkDialogs } from "./task-session-sidebar-selection"; import { useTaskRemoval } from "@/hooks/use-task-removal"; import { findTaskInSnapshots } from "@/lib/kanban/find-task"; @@ -152,6 +153,7 @@ function toSidebarItem( isArchived: false as boolean, parentTaskTitle: task.parentTaskId ? ctx.titleById.get(task.parentTaskId) : undefined, parentTaskId: task.parentTaskId ?? undefined, + workspaceMode: task.workspaceMode, prInfo: toPrInfo(pr), isPRReview: task.isPRReview ?? false, isIssueWatch: task.isIssueWatch ?? false, @@ -487,6 +489,7 @@ export function useSidebarActions(store: StoreApi) { const archiveActions = useArchiveActions(store); const deleteActions = useDeleteActions(store, removeTaskFromBoard); + const detachActions = useTaskDetachDialog(store); const linkActions = useSidebarLinkActions(store); const [renamingTask, setRenamingTask] = useState<{ id: string; title: string } | null>(null); @@ -521,6 +524,7 @@ export function useSidebarActions(store: StoreApi) { ...linkActions, ...archiveActions, ...deleteActions, + ...detachActions, }; } diff --git a/apps/web/components/task/task-switcher-action-items.tsx b/apps/web/components/task/task-switcher-action-items.tsx new file mode 100644 index 0000000000..2383198d1c --- /dev/null +++ b/apps/web/components/task/task-switcher-action-items.tsx @@ -0,0 +1,88 @@ +"use client"; + +import { IconArchive, IconLoader, IconTrash, IconUnlink } from "@tabler/icons-react"; +import { ContextMenuItem, ContextMenuSeparator } from "@kandev/ui/context-menu"; + +export function TaskArchiveItem({ + taskId, + actingIds, + actingOnSelection, + disabled, + onArchiveTask, + onBulkArchive, +}: { + taskId: string; + actingIds: string[]; + actingOnSelection: boolean; + disabled?: boolean; + onArchiveTask?: (taskId: string) => void; + onBulkArchive?: (taskIds: string[]) => void; +}) { + if (actingOnSelection && onBulkArchive) { + const count = actingIds.length; + return ( + onBulkArchive(actingIds)}> + + {count > 1 ? `Archive ${count} tasks` : "Archive"} + + ); + } + if (!onArchiveTask) return null; + return ( + onArchiveTask(taskId)}> + + Archive + + ); +} + +export function TaskDetachItem({ + task, + disabled, + onDetachTask, +}: { + task: { id: string; parentTaskId?: string | null }; + disabled?: boolean; + onDetachTask?: (taskId: string) => void; +}) { + if (!task.parentTaskId || !onDetachTask) return null; + return ( + onDetachTask(task.id)} + > + + Detach from parent + + ); +} + +export function TaskDeleteItem({ + taskId, + isDeleting, + onDeleteTask, +}: { + taskId: string; + isDeleting?: boolean; + onDeleteTask?: (taskId: string) => void; +}) { + if (!onDeleteTask) return null; + return ( + <> + + onDeleteTask(taskId)} + > + {isDeleting ? ( + + ) : ( + + )} + Delete + + + ); +} diff --git a/apps/web/components/task/task-switcher-context-menu.tsx b/apps/web/components/task/task-switcher-context-menu.tsx index cf41f985bd..b38ff8cbce 100644 --- a/apps/web/components/task/task-switcher-context-menu.tsx +++ b/apps/web/components/task/task-switcher-context-menu.tsx @@ -2,13 +2,11 @@ import { cloneElement, isValidElement, useState } from "react"; import { - IconArchive, IconBrandSentry, IconCopy, IconCircleDot, IconGitPullRequest, IconLink, - IconLoader, IconPencil, IconPin, IconPinFilled, @@ -31,6 +29,7 @@ import { } from "@/components/task/task-move-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { TaskColorMenu } from "./task-switcher-color-menu"; +import { TaskArchiveItem, TaskDeleteItem, TaskDetachItem } from "./task-switcher-action-items"; import type { TaskSwitcherItem } from "./task-switcher"; export type StepDef = { @@ -49,6 +48,7 @@ type ContextMenuProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onDetachTask?: (taskId: string) => void; onLinkPullRequest?: (taskId: string, taskTitle?: string) => void; onLinkIssue?: (taskId: string, taskTitle?: string) => void; onLinkJiraTicket?: (taskId: string, taskTitle?: string) => void; @@ -79,6 +79,7 @@ export function TaskItemWithContextMenu({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -119,6 +120,7 @@ export function TaskItemWithContextMenu({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} onLinkJiraTicket={onLinkJiraTicket} @@ -158,6 +160,7 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) { onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onMoveToStep, onTogglePin, isPinned, @@ -212,6 +215,7 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) { } : handler; const onDelete = withClear(onDeleteTask); + const onDetach = withClear(onDetachTask); return ( <> + ); @@ -412,41 +417,6 @@ function TaskRenameItem({ ); } -function TaskArchiveItem({ - taskId, - actingIds, - actingOnSelection, - disabled, - onArchiveTask, - onBulkArchive, -}: { - taskId: string; - actingIds: string[]; - actingOnSelection: boolean; - disabled?: boolean; - onArchiveTask?: (taskId: string) => void; - onBulkArchive?: (taskIds: string[]) => void; -}) { - // Acting on the selection routes through the bulk path (which clears the - // selection afterwards) even for a single selected row. - if (actingOnSelection && onBulkArchive) { - const n = actingIds.length; - return ( - onBulkArchive(actingIds)}> - - {n > 1 ? `Archive ${n} tasks` : "Archive"} - - ); - } - if (!onArchiveTask) return null; - return ( - onArchiveTask(taskId)}> - - Archive - - ); -} - function TaskMoveItems({ task, workflows, @@ -530,35 +500,6 @@ function TaskMoveItems({ ); } -function TaskDeleteItem({ - taskId, - isDeleting, - onDeleteTask, -}: { - taskId: string; - isDeleting?: boolean; - onDeleteTask?: (taskId: string) => void; -}) { - if (!onDeleteTask) return null; - return ( - <> - - onDeleteTask(taskId)} - > - {isDeleting ? ( - - ) : ( - - )} - Delete - - - ); -} - function TaskLinkMenu({ disabled, onLinkPullRequest, diff --git a/apps/web/components/task/task-switcher.test.tsx b/apps/web/components/task/task-switcher.test.tsx index da88c8f266..39099ddeee 100644 --- a/apps/web/components/task/task-switcher.test.tsx +++ b/apps/web/components/task/task-switcher.test.tsx @@ -165,6 +165,60 @@ describe("TaskSwitcher — bulk pin menu", () => { }); }); +describe("TaskSwitcher — detach menu", () => { + it("offers detach for a single subtask and invokes the action", () => { + const onDetachTask = vi.fn(); + render( + + + , + ); + + fireEvent.contextMenu(screen.getByText(CHILD.title)); + fireEvent.click(screen.getByRole("menuitem", { name: "Detach from parent" })); + + expect(onDetachTask).toHaveBeenCalledWith(CHILD.id); + }); + + it("omits detach for root and multi-selection menus", () => { + const { rerender } = render( + + + , + ); + + fireEvent.contextMenu(screen.getByText(ROOT.title)); + expect(screen.queryByRole("menuitem", { name: "Detach from parent" })).toBeNull(); + + rerender( + + + , + ); + fireEvent.contextMenu(screen.getByText(CHILD.title)); + expect(screen.queryByRole("menuitem", { name: "Detach from parent" })).toBeNull(); + }); +}); + describe("TaskSwitcher — external issue link menu", () => { it("offers configured external issue providers from the task context menu", async () => { render( diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 851e2f3b60..ee94a26aed 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -37,6 +37,7 @@ export type TaskSwitcherItem = { hasPendingPermission?: boolean; parentTaskTitle?: string; parentTaskId?: string; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; prInfo?: { number: number; state: string }; isPRReview?: boolean; isIssueWatch?: boolean; @@ -58,6 +59,7 @@ type TaskSwitcherProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; @@ -141,6 +143,7 @@ type TaskRowProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; @@ -193,6 +196,7 @@ function TaskRow({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onMoveToStep, onTogglePin, isPinned, @@ -222,6 +226,7 @@ function TaskRow({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onDetachTask={onDetachTask} {...taskLinkHandlerProps(props)} onMoveToStep={onMoveToStep} onTogglePin={onTogglePin} @@ -396,6 +401,7 @@ type GroupSectionProps = { onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; + onDetachTask?: (taskId: string) => void; onLinkPullRequest?: TaskLinkHandler; onLinkIssue?: TaskLinkHandler; onLinkJiraTicket?: TaskLinkHandler; @@ -435,6 +441,7 @@ function GroupSection({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -472,6 +479,7 @@ function GroupSection({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -527,6 +535,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onRenameTask, onArchiveTask, onDeleteTask, + onDetachTask, onLinkPullRequest, onLinkIssue, onLinkJiraTicket, @@ -582,6 +591,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ onRenameTask={onRenameTask} onArchiveTask={onArchiveTask} onDeleteTask={onDeleteTask} + onDetachTask={onDetachTask} onLinkPullRequest={onLinkPullRequest} onLinkIssue={onLinkIssue} onLinkJiraTicket={onLinkJiraTicket} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 9dd36bc3d7..4b629d3188 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -104,6 +104,8 @@ type CreateTaskOpts = { plan_mode?: boolean; metadata?: Record; parent_id?: string; + workspace_mode?: "inherit_parent" | "new_workspace" | "shared_group"; + workspace_group_id?: string; attachments?: MessageAttachmentInput[]; }; @@ -120,22 +122,25 @@ function buildCreateTaskBody( title: string, opts?: CreateTaskOpts, ): Record { + const options = opts ?? {}; const body: Record = { workspace_id: workspaceId, title, - description: opts?.description ?? "", + description: options.description ?? "", }; - setIf(body, "workflow_id", opts?.workflow_id); - setIf(body, "workflow_step_id", opts?.workflow_step_id); - setIf(body, "metadata", opts ? buildTaskMetadata(opts) : undefined); + setIf(body, "workflow_id", options.workflow_id); + setIf(body, "workflow_step_id", options.workflow_step_id); + setIf(body, "metadata", buildTaskMetadata(options)); setIf( body, "repositories", - opts?.repositories ?? opts?.repository_ids?.map((id) => ({ repository_id: id })), + options.repositories ?? options.repository_ids?.map((id) => ({ repository_id: id })), ); - setIf(body, "attachments", opts?.attachments); - if (opts?.plan_mode) body.plan_mode = true; - setIf(body, "parent_id", opts?.parent_id); + setIf(body, "attachments", options.attachments); + if (options.plan_mode) body.plan_mode = true; + setIf(body, "parent_id", options.parent_id); + setIf(body, "workspace_mode", options.workspace_mode); + setIf(body, "workspace_group_id", options.workspace_group_id); return body; } @@ -321,6 +326,10 @@ export class ApiClient { metadata?: Record; /** Parent task ID for subtasks. */ parent_id?: string; + /** Workspace sharing policy used by parent/child task trees. */ + workspace_mode?: "inherit_parent" | "new_workspace" | "shared_group"; + /** Existing group required when workspace_mode is shared_group. */ + workspace_group_id?: string; attachments?: MessageAttachmentInput[]; }, ): Promise { @@ -1220,6 +1229,8 @@ export class ApiClient { primary_session_id?: string | null; state?: string; workflow_step_id?: string; + parent_id?: string; + metadata?: Record | null; repositories?: Array<{ id: string; task_id: string; diff --git a/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts b/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts new file mode 100644 index 0000000000..387c03a619 --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts @@ -0,0 +1,68 @@ +import { test, expect } from "../../fixtures/test-base"; +import { SessionPage } from "../../pages/session-page"; + +test.describe("Mobile subtask detachment", () => { + test("detaches an inherited-workspace subtask from the task actions sheet", async ({ + testPage, + apiClient, + seedData, + }) => { + const placement = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }; + const parent = await apiClient.createTask( + seedData.workspaceId, + "Mobile detach parent", + placement, + ); + const child = await apiClient.createTask(seedData.workspaceId, "Mobile detach child", { + ...placement, + parent_id: parent.id, + workspace_mode: "inherit_parent", + }); + + await testPage.goto(`/t/${parent.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await testPage.getByTestId("mobile-session-menu").click(); + + const taskSheet = testPage.getByRole("dialog", { name: "Tasks" }); + const childRow = taskSheet + .getByTestId("sidebar-task-item") + .filter({ hasText: "Mobile detach child" }); + const actions = childRow.getByRole("button", { name: "Task actions" }); + await expect(actions).toBeVisible({ timeout: 10_000 }); + await actions.click(); + + const detachAction = testPage.getByRole("menuitem", { name: "Detach from parent" }); + await detachAction.scrollIntoViewIfNeeded(); + await detachAction.click(); + + const dialog = testPage.getByRole("alertdialog", { name: "Detach task from parent?" }); + await expect(dialog).toContainText("shares its parent's workspace"); + await dialog.getByTestId("detach-task-confirm").click(); + + await expect + .poll(async () => { + const detached = await apiClient.getTask(child.id); + const workspace = detached.metadata?.workspace as { mode?: string } | undefined; + return { parentId: detached.parent_id ?? "", workspaceMode: workspace?.mode }; + }) + .toEqual({ + parentId: "", + workspaceMode: "shared_group", + }); + + await testPage.getByTestId("mobile-session-menu").click(); + const promotedBlock = taskSheet.locator( + `[data-testid="sortable-task-block"][data-task-id="${child.id}"]`, + ); + await expect(promotedBlock).toHaveAttribute("data-depth", "0", { timeout: 10_000 }); + await promotedBlock + .getByTestId("sidebar-task-item") + .getByRole("button", { name: "Task actions" }) + .click(); + await expect(testPage.getByRole("menuitem", { name: "Detach from parent" })).toHaveCount(0); + }); +}); diff --git a/apps/web/e2e/tests/task/subtask-detachment.spec.ts b/apps/web/e2e/tests/task/subtask-detachment.spec.ts new file mode 100644 index 0000000000..137d8cb449 --- /dev/null +++ b/apps/web/e2e/tests/task/subtask-detachment.spec.ts @@ -0,0 +1,136 @@ +import { test, expect } from "../../fixtures/test-base"; +import { KanbanPage } from "../../pages/kanban-page"; +import { SessionPage } from "../../pages/session-page"; + +const DETACH_LABEL = "Detach from parent"; + +test.describe("Subtask detachment", () => { + test("promotes an inherited-workspace subtree live from the sidebar", async ({ + testPage, + apiClient, + seedData, + }) => { + const placement = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }; + const parent = await apiClient.createTask( + seedData.workspaceId, + "Detach sidebar parent", + placement, + ); + const child = await apiClient.createTask(seedData.workspaceId, "Detach sidebar child", { + ...placement, + parent_id: parent.id, + workspace_mode: "inherit_parent", + }); + const descendant = await apiClient.seedTask(seedData.workspaceId, "Detach sidebar descendant", { + ...placement, + parent_id: child.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}"]`); + await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "1"); + await expect(taskBlock(descendant.task_id)).toHaveAttribute("data-depth", "2"); + + const parentRow = session.sidebar + .getByTestId("sidebar-task-item") + .filter({ hasText: "Detach sidebar parent" }); + await parentRow.click({ button: "right" }); + await expect(testPage.getByRole("menuitem", { name: DETACH_LABEL })).toHaveCount(0); + await testPage.keyboard.press("Escape"); + + const childRow = session.sidebar + .getByTestId("sidebar-task-item") + .filter({ hasText: "Detach sidebar child" }); + await childRow.click({ button: "right" }); + await testPage.getByRole("menuitem", { name: DETACH_LABEL }).click(); + + const dialog = testPage.getByRole("alertdialog", { name: "Detach task from parent?" }); + await expect(dialog).toContainText("shares its parent's workspace"); + await expect(dialog).toContainText("Current and future sessions"); + await dialog.getByTestId("detach-task-confirm").click(); + + await expect(taskBlock(child.id)).toHaveAttribute("data-depth", "0", { timeout: 10_000 }); + await expect(taskBlock(descendant.task_id)).toHaveAttribute("data-depth", "1"); + await expect(taskBlock(child.id).locator(`[data-task-id="${descendant.task_id}"]`)).toHaveCount( + 1, + ); + + await expect + .poll(async () => { + const detached = await apiClient.getTask(child.id); + const workspace = detached.metadata?.workspace as { mode?: string } | undefined; + return { parentId: detached.parent_id ?? "", workspaceMode: workspace?.mode }; + }) + .toEqual({ + parentId: "", + workspaceMode: "shared_group", + }); + await expect + .poll(async () => (await apiClient.getTask(descendant.task_id)).parent_id) + .toBe(child.id); + + await childRow.click({ button: "right" }); + await expect(testPage.getByRole("menuitem", { name: DETACH_LABEL })).toHaveCount(0); + }); + + test("keeps card menus aligned and preserves kanban placement", async ({ + testPage, + apiClient, + seedData, + }) => { + const placement = { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }; + const parent = await apiClient.createTask( + seedData.workspaceId, + "Detach kanban parent", + placement, + ); + const child = await apiClient.createTask(seedData.workspaceId, "Detach kanban child", { + ...placement, + parent_id: parent.id, + workspace_mode: "new_workspace", + }); + const kanban = new KanbanPage(testPage); + await kanban.goto(); + + await kanban.openTaskActionsMenu(parent.id); + await expect(testPage.getByRole("menuitem", { name: DETACH_LABEL })).toHaveCount(0); + await testPage.keyboard.press("Escape"); + + await kanban.openTaskContextMenu(child.id); + await expect(testPage.getByRole("menuitem", { name: DETACH_LABEL })).toBeVisible(); + await testPage.keyboard.press("Escape"); + + await kanban.openTaskActionsMenu(child.id); + await testPage.getByRole("menuitem", { name: DETACH_LABEL }).click(); + await testPage.getByRole("alertdialog").getByTestId("detach-task-confirm").click(); + + await expect(kanban.taskCardInColumn("Detach kanban child", seedData.startStepId)).toBeVisible({ + timeout: 10_000, + }); + await expect + .poll(async () => { + const detached = await apiClient.getTask(child.id); + const workspace = detached.metadata?.workspace as { mode?: string } | undefined; + return { + parentId: detached.parent_id ?? "", + stepId: detached.workflow_step_id, + workspaceMode: workspace?.mode, + }; + }) + .toEqual({ + parentId: "", + stepId: seedData.startStepId, + workspaceMode: "new_workspace", + }); + }); +}); diff --git a/apps/web/hooks/use-detach-task.test.tsx b/apps/web/hooks/use-detach-task.test.tsx new file mode 100644 index 0000000000..ad61dc8fdb --- /dev/null +++ b/apps/web/hooks/use-detach-task.test.tsx @@ -0,0 +1,68 @@ +import { act, renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Task } from "@/lib/types/http"; +import { useDetachTask } from "./use-detach-task"; + +const detachTaskMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/api", () => ({ detachTask: detachTaskMock })); + +describe("useDetachTask", () => { + beforeEach(() => { + detachTaskMock.mockReset(); + }); + + it("reuses the in-flight request for repeated submissions", async () => { + let resolveRequest!: (task: Task) => void; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + detachTaskMock.mockReturnValueOnce(request); + const { result } = renderHook(() => useDetachTask()); + + let first!: Promise; + let second!: Promise; + act(() => { + first = result.current.detachTask("child-1"); + second = result.current.detachTask("child-1"); + }); + + expect(second).toBe(first); + expect(detachTaskMock).toHaveBeenCalledOnce(); + + await act(async () => { + resolveRequest({ id: "child-1" } as Task); + await first; + }); + expect(result.current.detachingTaskId).toBeNull(); + }); + + it("reuses the in-flight request across hook instances", async () => { + let resolveRequest!: (task: Task) => void; + const request = new Promise((resolve) => { + resolveRequest = resolve; + }); + detachTaskMock.mockReturnValueOnce(request); + const firstHook = renderHook(() => useDetachTask()); + const secondHook = renderHook(() => useDetachTask()); + + let first!: Promise; + let second!: Promise; + act(() => { + first = firstHook.result.current.detachTask("child-1"); + second = secondHook.result.current.detachTask("child-1"); + }); + + expect(second).toBe(first); + expect(detachTaskMock).toHaveBeenCalledOnce(); + expect(firstHook.result.current.detachingTaskId).toBe("child-1"); + expect(secondHook.result.current.detachingTaskId).toBe("child-1"); + + await act(async () => { + resolveRequest({ id: "child-1" } as Task); + await first; + }); + expect(firstHook.result.current.detachingTaskId).toBeNull(); + expect(secondHook.result.current.detachingTaskId).toBeNull(); + }); +}); diff --git a/apps/web/hooks/use-detach-task.ts b/apps/web/hooks/use-detach-task.ts new file mode 100644 index 0000000000..089a255bb3 --- /dev/null +++ b/apps/web/hooks/use-detach-task.ts @@ -0,0 +1,87 @@ +"use client"; + +import { useCallback, useState } from "react"; +import type { StoreApi } from "zustand"; +import { toast } from "sonner"; +import { detachTask as requestDetachTask } from "@/lib/api"; +import type { Task } from "@/lib/types/http"; +import type { AppState } from "@/lib/state/store"; +import { findTaskInSnapshots } from "@/lib/kanban/find-task"; + +type DetachTarget = { + id: string; + title: string; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; +}; + +const detachRequests = new Map>(); + +function requestDetachOnce(taskId: string): Promise { + const existing = detachRequests.get(taskId); + if (existing) return existing; + + const request = requestDetachTask(taskId).catch((error) => { + toast.error(error instanceof Error ? error.message : "Failed to detach task"); + throw error; + }); + detachRequests.set(taskId, request); + + const clearRequest = () => { + if (detachRequests.get(taskId) === request) detachRequests.delete(taskId); + }; + void request.then(clearRequest, clearRequest); + return request; +} + +export function useDetachTask() { + const [detachingTaskId, setDetachingTaskId] = useState(null); + + const detachTask = useCallback((taskId: string): Promise => { + setDetachingTaskId(taskId); + const request = requestDetachOnce(taskId); + const clearLocalState = () => { + setDetachingTaskId((current) => (current === taskId ? null : current)); + }; + void request.then(clearLocalState, clearLocalState); + return request; + }, []); + + return { detachTask, detachingTaskId }; +} + +export function useTaskDetachDialog(store: StoreApi) { + const { detachTask, detachingTaskId } = useDetachTask(); + const [detachingTask, setDetachingTask] = useState(null); + + const handleDetachTask = useCallback( + (taskId: string) => { + const state = store.getState(); + const task = findTaskInSnapshots(taskId, state.kanbanMulti.snapshots, state.kanban.tasks); + if (!task?.parentTaskId) return; + setDetachingTask({ + id: task.id, + title: task.title, + workspaceMode: task.workspaceMode, + }); + }, + [store], + ); + + const handleDetachConfirm = useCallback(async () => { + if (!detachingTask) return; + try { + await detachTask(detachingTask.id); + setDetachingTask(null); + } catch (error) { + console.error("Failed to detach task:", error); + } + }, [detachTask, detachingTask]); + + return { + detachingTask, + setDetachingTask, + detachingTaskId, + handleDetachTask, + handleDetachConfirm, + }; +} diff --git a/apps/web/hooks/use-optimistic-task-mutation.ts b/apps/web/hooks/use-optimistic-task-mutation.ts index c2694412dc..c17d546ed4 100644 --- a/apps/web/hooks/use-optimistic-task-mutation.ts +++ b/apps/web/hooks/use-optimistic-task-mutation.ts @@ -89,7 +89,7 @@ function toOfficeTaskPatch(patch: Partial): Partial { out.assigneeAgentProfileId = patch.assigneeAgentProfileId; } if (patch.projectId !== undefined) out.projectId = patch.projectId; - if (patch.parentId !== undefined) out.parentId = patch.parentId; + if (Object.prototype.hasOwnProperty.call(patch, "parentId")) out.parentId = patch.parentId; if (patch.labels !== undefined) out.labels = patch.labels; if (patch.blockedBy !== undefined) out.blockedBy = patch.blockedBy; return out; diff --git a/apps/web/lib/api/domains/kanban-api.test.ts b/apps/web/lib/api/domains/kanban-api.test.ts new file mode 100644 index 0000000000..4a6cff0df8 --- /dev/null +++ b/apps/web/lib/api/domains/kanban-api.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { detachTask } from "./kanban-api"; + +const fetchSpy = vi.fn(); + +beforeEach(() => { + fetchSpy.mockReset(); + vi.stubGlobal("fetch", fetchSpy); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("detachTask", () => { + it("posts without a body to the canonical detach endpoint", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ id: "child-1", parent_id: "" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + + await detachTask("child-1", { baseUrl: "http://api.test" }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("http://api.test/api/v1/tasks/child-1/detach"); + expect(init?.method).toBe("POST"); + expect(init?.body).toBeUndefined(); + }); +}); diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index b809ba9257..74b2b5f967 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -118,6 +118,13 @@ export async function updateTask( }); } +export async function detachTask(taskId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/tasks/${taskId}/detach`, { + ...options, + init: { method: "POST", ...(options?.init ?? {}) }, + }); +} + export async function updateTaskRepositoryBaseBranch( taskId: string, taskRepositoryId: string, diff --git a/apps/web/lib/kanban/map-task.test.ts b/apps/web/lib/kanban/map-task.test.ts index 7d6541ca18..2b2495d79b 100644 --- a/apps/web/lib/kanban/map-task.test.ts +++ b/apps/web/lib/kanban/map-task.test.ts @@ -155,4 +155,16 @@ describe("toKanbanTask — HTTP DTO / WS payload parity", () => { expect(toKanbanTask(http).isRemoteExecutor).toBe(false); expect(toKanbanTask(ws).isRemoteExecutor).toBe(false); }); + + it("clears the parent and retains shared workspace mode from a detach update", () => { + const detached = toKanbanTask( + wsPayload({ + parent_id: null, + metadata: { workspace: { mode: "shared_group" } }, + }), + ); + + expect(detached.parentTaskId).toBeUndefined(); + expect(detached.workspaceMode).toBe("shared_group"); + }); }); diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 27ec5a22e3..119b6162e3 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -48,6 +48,20 @@ export type TaskLike = { metadata?: Record | null; }; +export type WorkspaceMode = "inherit_parent" | "new_workspace" | "shared_group"; + +export function workspaceModeFromMetadata( + metadata: TaskLike["metadata"], +): WorkspaceMode | undefined { + const workspace = metadata?.workspace; + if (!workspace || typeof workspace !== "object") return undefined; + const mode = (workspace as Record).mode; + if (mode === "inherit_parent" || mode === "new_workspace" || mode === "shared_group") { + return mode; + } + return undefined; +} + function pickRepositoryId(source: TaskLike): string | undefined { return source.repository_id ?? source.repositories?.[0]?.repository_id ?? undefined; } @@ -102,6 +116,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { primaryExecutorName: source.primary_executor_name ?? undefined, isRemoteExecutor: source.is_remote_executor ?? false, parentTaskId: source.parent_id ?? undefined, + workspaceMode: workspaceModeFromMetadata(source.metadata), updatedAt: source.updated_at, createdAt: source.created_at, isPRReview: isPRReviewFromMetadata(source.metadata), diff --git a/apps/web/lib/ssr/mapper.ts b/apps/web/lib/ssr/mapper.ts index 3ff63b99c5..e6f82449bd 100644 --- a/apps/web/lib/ssr/mapper.ts +++ b/apps/web/lib/ssr/mapper.ts @@ -1,7 +1,7 @@ import type { AppState, KanbanState } from "@/lib/state/store"; import { primaryTaskRepository } from "@/lib/types/http"; import type { WorkflowSnapshot, Message, Task } from "@/lib/types/http"; -import { pickPendingAction } from "@/lib/kanban/map-task"; +import { pickPendingAction, workspaceModeFromMetadata } from "@/lib/kanban/map-task"; import { isPRReviewFromMetadata, isIssueWatchFromMetadata, @@ -50,6 +50,7 @@ export function snapshotToState(snapshot: WorkflowSnapshot): Partial { sessionCount: task.session_count ?? undefined, reviewStatus: task.review_status ?? undefined, parentTaskId: task.parent_id ?? undefined, + workspaceMode: workspaceModeFromMetadata(task.metadata), updatedAt: task.updated_at, isPRReview: isPRReviewFromMetadata(task.metadata), isIssueWatch: isIssueWatchFromMetadata(task.metadata), diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index 0ffcd61cc4..1782e1290c 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -69,6 +69,7 @@ export type KanbanState = { primaryExecutorName?: string | null; isRemoteExecutor?: boolean; parentTaskId?: string | null; + workspaceMode?: "inherit_parent" | "new_workspace" | "shared_group"; updatedAt?: string; createdAt?: string; isPRReview?: boolean; diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index e364542de2..05ba740c4c 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -91,6 +91,8 @@ export type TaskEventPayload = { archived_at?: string | null; updated_at?: string; is_ephemeral: boolean; + parent_id?: string | null; + metadata?: Record | null; /** Deletion reason on task.deleted (e.g. "pr_approved_by_user"). Absent otherwise. */ reason?: string; }; diff --git a/docs/plans/subtask-detachment/plan.md b/docs/plans/subtask-detachment/plan.md new file mode 100644 index 0000000000..441506d3ad --- /dev/null +++ b/docs/plans/subtask-detachment/plan.md @@ -0,0 +1,102 @@ +--- +spec: docs/specs/tasks/subtask-detachment.md +created: 2026-07-18 +status: complete +--- + +# Implementation Plan: Subtask detachment + +## Overview + +Add one canonical backend detach operation that atomically clears hierarchy and normalizes inherited workspace policy, then expose it through the shared task-menu surfaces. Backend and frontend implementation can proceed independently after the HTTP contract is fixed; integrated desktop/mobile E2E follows both. + +## Backend + +### Canonical service operation + +- Add `Service.DetachTask(ctx, taskID)` in `apps/backend/internal/task/service/service_detachment.go`. +- Load the task, return roots unchanged, and otherwise clear `ParentID` while preserving all unrelated fields. +- Copy and normalize `Metadata["workspace"]`: change only `mode: "inherit_parent"` to `mode: "shared_group"`. +- Persist through the task repository, reload repositories for the response, and publish `task.updated`. +- Update `apps/backend/internal/task/service/service_events.go` so `parent_id` is always present in task lifecycle payloads, including its cleared value. + +### HTTP contract and Office parity + +- Register `POST /api/v1/tasks/:id/detach` in `apps/backend/internal/task/handlers/task_handlers.go` and implement the handler beside existing task HTTP handlers. +- Return the updated task DTO, map missing tasks to `404`, and surface persistence errors consistently. +- Route the Office dashboard's empty-parent mutation through the canonical detach operation while leaving non-empty reparenting behavior unchanged. + +## Frontend + +### API and action state + +- Add `detachTask(taskId)` to `apps/web/lib/api/domains/kanban-api.ts` and its barrel export. +- Add a focused detach action hook that calls the endpoint, reports failure through the existing toast pattern, and prevents duplicate confirmation submissions. +- Ensure the task mapper/store retains the workspace mode needed to render the shared-workspace warning. + +### Menus and confirmation + +- Add a reusable `TaskDetachConfirmDialog` under `apps/web/components/task/` with concise hierarchy-only copy and a conditional shared-workspace warning. +- Add `Detach from parent` with the existing unlink icon to `apps/web/components/task/task-switcher-context-menu.tsx` for single subtasks only. +- Thread the action through `TaskSwitcher`, desktop sidebar actions, and mobile action presentation. +- Add the same entry to `apps/web/components/kanban-card-menu-items.tsx`; the shared entry builder keeps card right-click and three-dot menus aligned. +- Route the Office parent picker's `No parent` selection through `detachTask`; non-empty parent changes continue using the Office update API. + +## Tests + +- **Canonical detach:** table-driven service tests cover root no-op, ordinary child, `inherit_parent`, `shared_group`, and `new_workspace` modes in `apps/backend/internal/task/service/service_detachment_test.go`. +- **Relationship preservation:** service tests prove descendants, blockers, repositories, sessions, and workspace-group rows are not mutated by the task-row operation. +- **Event clearing:** service/event tests assert `task.updated.parent_id` is present and empty after detach. +- **HTTP integration:** handler tests exercise success, root idempotency, missing task, and persistence failure. +- **Menu visibility:** component tests assert the action is present only for a subtask and hidden for root and multi-selection menus. +- **Card parity:** `apps/web/components/kanban-card-menu-items.test.tsx` asserts both menu renderers receive the detach entry and invoke the action. +- **Mapper/state:** frontend tests assert a cleared `parent_id` removes cached nesting and workspace mode is retained for confirmation copy. +- **Office picker:** component tests assert `No parent` calls the detach endpoint while selecting a parent uses the existing update endpoint. + +## E2E Tests + +- Add `apps/web/e2e/tests/task/subtask-detachment.spec.ts` for sidebar right-click, confirmation, live root promotion, card three-dot parity, root-menu absence, and descendant preservation. +- Add `apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts` for the touch-accessible task action and confirmation flow on `mobile-chrome`. +- Seed an inherited-workspace subtask and assert the confirmation warning and post-detach workspace context remain shared. + +## Implementation Waves + +Wave 1 (parallel): + +- [x] [Task 01: Backend detach contract](task-01-backend-detachment.md) (`done`) +- [x] [Task 02: Frontend detach actions](task-02-frontend-actions.md) (`done`) + +Wave 2: + +- [x] [Task 03: E2E and verification](task-03-e2e-and-verification.md) (`done`) + +## Verification + +From `apps/backend`: + +```bash +rtk go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard +``` + +From `apps/web`: + +```bash +rtk pnpm test -- components/task/task-switcher.test.tsx components/kanban-card-menu-items.test.tsx components/task/simple/components/parent-picker.test.tsx lib/kanban/map-task.test.ts +rtk pnpm run typecheck +rtk pnpm run lint +rtk pnpm e2e:run e2e/tests/task/subtask-detachment.spec.ts e2e/tests/task/mobile-subtask-detachment.spec.ts +``` + +From the repository root after `make fmt`: + +```bash +rtk make typecheck +rtk make test +rtk make lint +``` + +## Risks + +- Omitting a cleared `parent_id` from WebSocket payloads would leave existing clients visually nested until reload. +- Leaving `inherit_parent` after clearing the parent would silently provision a separate workspace on a future launch. +- Mobile context menus are not assumed to be touch-accessible; the action must be wired through the existing mobile action presentation. diff --git a/docs/plans/subtask-detachment/task-01-backend-detachment.md b/docs/plans/subtask-detachment/task-01-backend-detachment.md new file mode 100644 index 0000000000..7faba553d9 --- /dev/null +++ b/docs/plans/subtask-detachment/task-01-backend-detachment.md @@ -0,0 +1,48 @@ +--- +id: "01-backend-detachment" +title: "Backend detach contract" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/subtask-detachment.md" +--- + +# Task 01: Backend detach contract + +## Acceptance + +- `POST /api/v1/tasks/:id/detach` clears only the parent relationship and returns the updated task; root calls are idempotent. +- Detaching an `inherit_parent` task atomically stores `shared_group` mode without releasing workspace membership; other modes remain unchanged. +- The resulting `task.updated` payload explicitly clears `parent_id`, and the Office `No parent` mutation uses the same semantic operation. + +## Verification + +```bash +cd apps/backend && rtk go test ./internal/task/service ./internal/task/handlers ./internal/office/dashboard +``` + +## Files likely touched + +- `apps/backend/internal/task/service/service_detachment.go` +- `apps/backend/internal/task/service/service_detachment_test.go` +- `apps/backend/internal/task/service/service_events.go` +- `apps/backend/internal/task/handlers/task_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers_test.go` +- `apps/backend/internal/office/dashboard/service_tasks.go` +- `apps/backend/internal/office/dashboard/handler.go` + +## Dependencies + +None. + +## Inputs + +- Spec sections: What, Data model, API surface, Failure modes, Scenarios. +- Existing update pattern: `Service.UpdateTask` and `httpUpdateTask`. +- Existing workspace policy: `WorkspacePolicy.MetadataBlock` and orchestrator `shared_group` inheritance. + +## Output contract + +Report the service/handler changes, tests run, files changed, blockers, residual risks, and update this task plus `plan.md` to `done` when acceptance passes. diff --git a/docs/plans/subtask-detachment/task-02-frontend-actions.md b/docs/plans/subtask-detachment/task-02-frontend-actions.md new file mode 100644 index 0000000000..c699fb2195 --- /dev/null +++ b/docs/plans/subtask-detachment/task-02-frontend-actions.md @@ -0,0 +1,55 @@ +--- +id: "02-frontend-actions" +title: "Frontend detach actions" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/subtask-detachment.md" +--- + +# Task 02: Frontend detach actions + +## Acceptance + +- A single subtask exposes `Detach from parent` in the sidebar context menu and in both task-card menus; root and bulk menus do not. +- Confirmation states that hierarchy changes while shared workspace access remains, and successful submission cannot be duplicated. +- The Office `No parent` picker and mobile task actions invoke the canonical detach endpoint. + +## Verification + +```bash +cd apps/web && rtk pnpm test -- components/task/task-switcher.test.tsx components/kanban-card-menu-items.test.tsx components/task/simple/components/parent-picker.test.tsx lib/kanban/map-task.test.ts +cd apps/web && rtk pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/lib/api/domains/kanban-api.ts` +- `apps/web/lib/api/index.ts` +- `apps/web/lib/kanban/map-task.ts` +- `apps/web/lib/state/slices/kanban/types.ts` +- `apps/web/hooks/use-detach-task.ts` +- `apps/web/components/task/task-detach-confirm-dialog.tsx` +- `apps/web/components/task/task-switcher-context-menu.tsx` +- `apps/web/components/task/task-switcher.tsx` +- `apps/web/components/task/task-session-sidebar.tsx` +- `apps/web/components/task/mobile/session-task-switcher-sheet.tsx` +- `apps/web/components/kanban-card-menu-items.tsx` +- `apps/web/components/kanban-card.tsx` +- `apps/web/components/task/simple/components/parent-picker.tsx` +- Adjacent component and hook test files. + +## Dependencies + +None. Implement against the approved HTTP contract; integrated verification depends on Task 01. + +## Inputs + +- Spec sections: What, API surface, Scenarios. +- Existing menu patterns in `task-switcher-context-menu.tsx` and `kanban-card-menu-items.tsx`. +- Mobile parity rules in `.agents/skills/mobile-parity/SKILL.md`. + +## Output contract + +Report UI/API changes, tests run, files changed, blockers, mobile risks, and update this task plus `plan.md` to `done` when acceptance passes. diff --git a/docs/plans/subtask-detachment/task-03-e2e-and-verification.md b/docs/plans/subtask-detachment/task-03-e2e-and-verification.md new file mode 100644 index 0000000000..84718cf29a --- /dev/null +++ b/docs/plans/subtask-detachment/task-03-e2e-and-verification.md @@ -0,0 +1,53 @@ +--- +id: "03-e2e-and-verification" +title: "E2E and verification" +status: done +wave: 2 +depends_on: ["01-backend-detachment", "02-frontend-actions"] +plan: "plan.md" +spec: "../../specs/tasks/subtask-detachment.md" +--- + +# Task 03: E2E and verification + +## Acceptance + +- Desktop E2E proves sidebar and three-dot detachment, live root promotion, root-menu absence, workspace warning, and descendant preservation. +- Mobile E2E proves the touch-accessible action and confirmation produce the same outcome. +- Focused checks and the repository format, typecheck, test, and lint pipeline pass. + +## Verification + +```bash +cd apps/web && rtk pnpm e2e:run --host e2e/tests/task/subtask-detachment.spec.ts +cd apps/web && rtk pnpm e2e:run --host --no-build --project mobile-chrome e2e/tests/task/mobile-subtask-detachment.spec.ts +rtk make fmt +rtk make typecheck +rtk make test +rtk make lint +``` + +All acceptance checks passed. The desktop project covers sidebar and card actions, and the mobile project covers the touch action sheet. Repository formatting, typecheck, tests, and lint pass; E2E runs require local listener access outside the filesystem sandbox. + +## Files likely touched + +- `apps/web/e2e/tests/task/subtask-detachment.spec.ts` +- `apps/web/e2e/tests/task/mobile-subtask-detachment.spec.ts` +- `apps/web/e2e/pages/session-page.ts` +- `apps/web/e2e/pages/kanban-page.ts` +- Any focused regression test files identified during QA. + +## Dependencies + +- Task 01: Backend detach contract. +- Task 02: Frontend detach actions. + +## Inputs + +- All spec scenarios. +- Existing sidebar context-menu and kanban action-menu E2E patterns. +- `.agents/skills/e2e/SKILL.md`, `.agents/skills/mobile-parity/SKILL.md`, and `.agents/skills/verify/SKILL.md`. + +## Output contract + +Report E2E evidence, complete verification commands and outcomes, files changed, blockers, remaining risk, and update this task plus `plan.md` to `done` when acceptance passes. diff --git a/docs/public/coordination.md b/docs/public/coordination.md index 0e2610b3a2..8c9cc417c0 100644 --- a/docs/public/coordination.md +++ b/docs/public/coordination.md @@ -81,6 +81,12 @@ The subtask dialog currently does not enforce agent/executor credential compatib Regular Kanban allows one subtask level: a root task can have children, but a child cannot have another child. Split further work into sibling subtasks, additional sessions, or a separate top-level task. Arbitrary-depth trees belong to the in-progress Office surface. +### Detach a subtask + +Open the subtask's action menu from its sidebar entry or Kanban card, choose **Detach from parent**, and confirm. The subtask becomes a top-level task without changing its workflow position, blockers, sessions, or descendants. The Office parent picker's **No parent** choice performs the same operation. + +Detaching changes task hierarchy only. An inherited workspace remains shared with the former parent, and the confirmation dialog calls this out explicitly. Create a task with **Create new workspace** when the work needs isolated files or a separate branch. + ### Create a subtask from an agent Call `create_task_kandev` with `parent_id: "self"`. `workspace_mode` defaults to `inherit_parent`; set it to `new_workspace` for isolated materialization. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index bc2d6f38a7..00e6eb6183 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -225,8 +225,18 @@ "audiences": ["operator"], "stability": "stable", "docs": ["operations", "configuration", "feature-status"], - "sources": ["apps/backend/internal/system/settings/store.go", "apps/web/components/settings/system/feature-toggles-settings.tsx"], - "tests": ["apps/backend/internal/system/settings/store_test.go", "apps/web/components/settings/system/feature-toggles-settings.test.tsx"], + "sources": [ + "apps/backend/internal/system/settings/store.go", + "apps/backend/internal/system/storage/handler.go", + "apps/web/components/settings/system/feature-toggles-settings.tsx", + "apps/web/components/settings/system/storage/storage-maintenance-settings.tsx" + ], + "tests": [ + "apps/backend/internal/system/settings/store_test.go", + "apps/backend/internal/system/storage/handler_test.go", + "apps/web/components/settings/system/feature-toggles-settings.test.tsx", + "apps/web/components/settings/system/storage/storage-maintenance-settings.test.tsx" + ], "settingsRoutes": [ "/settings/system/about", "/settings/system/backups", @@ -235,6 +245,7 @@ "/settings/system/licenses", "/settings/system/logs", "/settings/system/status", + "/settings/system/storage", "/settings/system/updates" ] }, diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 75dfc704c2..183d0d42b4 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -105,6 +105,7 @@ On desktop and tablet, the header switches between **Kanban**, **Pipeline**, and - **Show archived** reveals archived tasks in List. - List page sizes are 10, 25, or 50; the default is 25. - Parent tasks and direct subtasks are indented as a tree. +- A subtask's action menu can detach it into a top-level task. Detaching preserves its workflow position and descendants; an inherited workspace remains shared with the former parent. On phones, Kanban focuses one workflow and one step at a time. The board navigator always names both; open it to choose either level, or use the previous/next controls and horizontal swipe to move between steps. Choosing a workflow makes it the active workflow for board actions and task creation. Tap a card to open that task directly. Its **More options** menu opens as a touch-sized bottom surface; **Move to** changes the task's workflow or step. **Edit** can still rename a task after work starts, while its original prompt remains locked. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index e65090642b..dfe350df60 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -50,6 +50,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [model-unification](tasks/model-unification.md) | draft | | [without-repositories](tasks/without-repositories.md) | draft | | [subtask-checklist](tasks/subtask-checklist.md) | shipped | +| [subtask-detachment](tasks/subtask-detachment.md) | shipped | | [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 new file mode 100644 index 0000000000..554ddd1275 --- /dev/null +++ b/docs/specs/tasks/subtask-detachment.md @@ -0,0 +1,91 @@ +--- +status: shipped +created: 2026-07-18 +owner: kandev +--- + +# Subtask detachment + +## Why + +Users can create and reorganize task trees, but they cannot promote an existing subtask to a top-level task from the task menus. Clearing only the hierarchy relationship is also unsafe for subtasks that inherit their parent's materialized workspace because future launches would lose the sharing policy. + +## What + +- A user can detach a subtask from its parent from the sidebar task context menu and the task card's three-dot and context menus. +- The action is shown only for tasks with a parent and requires confirmation. +- Confirmation explains that detachment changes task hierarchy only and that a workspace shared with the parent remains shared. +- Detachment clears the task's parent relationship without changing its workflow, workflow step, state, blockers, descendants, repositories, sessions, or agent execution. +- A detached task becomes a top-level root of its existing subtree. Its descendants remain attached to it. +- A task using `inherit_parent` workspace mode changes to `shared_group` mode when detached. Its workspace-group membership remains active, so current and future sessions continue using the same materialized workspace. +- Tasks already using `shared_group` or `new_workspace` keep that workspace mode. +- Detaching an already-root task is idempotent and succeeds without changing the task. +- Choosing `No parent` in the Office parent picker uses the same detachment behavior. +- Successful detachment is reflected in all connected board, sidebar, and task-detail views without a reload. + +## Data model + +Detachment updates existing persisted fields; it adds no table or column. + +- `tasks.parent_id` becomes the empty string. +- `tasks.metadata.workspace.mode` changes from `inherit_parent` to `shared_group` when applicable. +- `task_workspace_group_members` is unchanged. Active membership remains the durable source of shared workspace access. +- Existing blocker rows, task-session rows, task-environment rows, and descendant `parent_id` values are unchanged. + +The hierarchy update and workspace-mode update are persisted in the same task-row update. + +## API surface + +`POST /api/v1/tasks/:id/detach` + +- Request body: none. +- Success: `200` with the updated task DTO. An already-root task returns its unchanged DTO. +- Missing task: `404`. +- Persistence failure: `500`; no successful response is returned. + +On success, the backend publishes `task.updated`. Its payload includes `parent_id` even when cleared so clients can remove a previously cached parent relationship. When metadata changes, the payload includes the updated workspace policy. + +## State transition + +The operation has one transition: + +`child(parent_id != "")` -> `root(parent_id = "")` + +If workspace mode is `inherit_parent`, the same operation also transitions it to `shared_group`. No task, session, workflow, or executor lifecycle state changes. + +## Failure modes + +- If the task no longer exists, the UI keeps its current state and shows the request error. +- If persistence fails, neither the UI nor API reports success. The task remains attached according to durable state. +- Repeated submissions are safe because detaching an already-root task is a no-op. +- Detachment does not create, copy, move, clean, or delete workspace files. + +## Persistence guarantees + +The cleared parent relationship and normalized workspace mode survive backend restarts. Existing workspace-group membership and materialized workspace ownership remain unchanged and follow the existing cleanup lifecycle for the group. + +## Scenarios + +- **GIVEN** a subtask in the sidebar, **WHEN** the user chooses `Detach from parent` from its right-click menu and confirms, **THEN** the task appears as a top-level task without a page reload. +- **GIVEN** a subtask card on the kanban board, **WHEN** the user detaches it from the three-dot menu, **THEN** the card remains in the same workflow step and no longer belongs to its former parent. +- **GIVEN** a root task, **WHEN** either task menu opens, **THEN** `Detach from parent` is not shown. +- **GIVEN** an `inherit_parent` subtask, **WHEN** the confirmation opens, **THEN** it warns that the shared workspace remains shared. +- **GIVEN** an `inherit_parent` subtask with an active workspace-group membership, **WHEN** it is detached, **THEN** its mode becomes `shared_group`, its membership remains active, and later sessions resolve the same materialized environment. +- **GIVEN** a `new_workspace` or `shared_group` subtask, **WHEN** it is detached, **THEN** its workspace mode and workspace-group membership remain unchanged. +- **GIVEN** a subtask with descendants and blocker relationships, **WHEN** it is detached, **THEN** its descendants remain nested beneath it and all blocker relationships remain unchanged. +- **GIVEN** an Office task with a parent, **WHEN** the user chooses `No parent` in the parent picker, **THEN** the canonical detach operation applies the same workspace behavior. +- **GIVEN** a detached task update received over WebSocket, **WHEN** the client merges it into cached task state, **THEN** the prior parent relationship is cleared rather than preserved. +- **GIVEN** the mobile task switcher, **WHEN** a user opens the touch-accessible actions for a subtask, **THEN** the same detach and confirmation flow is available. + +## Out of scope + +- Reparenting a task to a different parent from the sidebar or kanban card menus. +- Copying or provisioning a separate workspace during detachment. +- Removing blocker relationships or changing descendant relationships. +- Stopping or restarting active sessions. +- Bulk detachment. +- Synthesizing an `on_children_completed` trigger for the former parent. + +## Implementation plan + +See [the implementation plan](../../plans/subtask-detachment/plan.md).