feat: allow detaching subtasks from parent tasks (#1781)

This commit is contained in:
Carlos Florêncio
2026-07-18 21:19:05 +01:00
committed by GitHub
parent 18841d6c00
commit e9634fa636
51 changed files with 2189 additions and 103 deletions
+3
View File
@@ -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),
@@ -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).
@@ -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)
}
}
@@ -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
}
@@ -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 {
@@ -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 &copy, 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())
}
@@ -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)
@@ -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"`
}
@@ -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 {
@@ -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)
}
}
@@ -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))
}
}
@@ -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
}
+1
View File
@@ -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[];
@@ -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);
});
});
+35 -1
View File
@@ -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<BuildKanbanCardMenuEntriesArgs, "parentTaskId" | "onDetach" | "isDetaching"> & {
isProcessing: boolean;
}): KanbanCardMenuEntry | null {
if (!parentTaskId || !onDetach) return null;
return {
kind: "item",
key: "detach",
testId: "task-context-detach",
icon: isDetaching ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconUnlink className="mr-2 h-4 w-4" />
),
label: "Detach from parent",
disabled: isProcessing,
onSelect: onDetach,
};
}
export function useKanbanCardMoveTargets(
taskId: string,
steps?: WorkflowStep[],
+33 -1
View File
@@ -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<ExternalLinkProvider | null>(
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 })}
/>
<TaskDetachConfirmDialog
open={menu.showDetachConfirm}
onOpenChange={menu.setShowDetachConfirm}
taskTitle={task.title}
sharesParentWorkspace={task.workspaceMode === "inherit_parent"}
isDetaching={menu.isDetaching}
onConfirm={menu.handleDetachConfirm}
/>
<TaskGitHubPRDialog
open={menu.showPRDialog}
onOpenChange={menu.setShowPRDialog}
@@ -8,13 +8,14 @@ import { launchSession } from "@/lib/services/session-launch-service";
import { buildPrepareRequest } from "@/lib/services/session-launch-helpers";
import { useWorkspaceSidebarTasks } from "@/hooks/domains/kanban/use-workspace-sidebar-tasks";
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
import { useTaskDetachDialog } from "@/hooks/use-detach-task";
import { useTaskRemoval } from "@/hooks/use-task-removal";
import { getSessionInfoForTask } from "@/lib/utils/session-info";
import {
hasPendingClarification,
hasPendingPermissionRequest,
} from "@/lib/utils/pending-clarification";
import { toKanbanTask } from "@/lib/kanban/map-task";
import { toKanbanTask, workspaceModeFromMetadata } from "@/lib/kanban/map-task";
import {
repositoryId as toRepositoryId,
type TaskState,
@@ -98,6 +99,7 @@ function toSheetItem(
// Carry the parent link so the mobile task switcher nests subtasks the same
// way the desktop sidebar does (applyView/TaskSwitcher read parentTaskId).
parentTaskId: task.parentTaskId ?? undefined,
workspaceMode: task.workspaceMode,
state: task.state as TaskState | undefined,
sessionState: resolvedSessionState,
description: task.description,
@@ -377,6 +379,7 @@ function buildKanbanTaskUpsert(
return {
id: task.id,
parentTaskId: task.parent_id ?? undefined,
workspaceMode: workspaceModeFromMetadata(task.metadata),
workflowStepId: task.workflow_step_id,
title: task.title,
description: task.description,
@@ -562,6 +565,7 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
const archiveAndSwitch = useArchiveAndSwitchTask();
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store });
const deleteActions = useSheetDeleteActions(store, removeTaskFromBoard);
const detachActions = useTaskDetachDialog(store);
const handleSelectTask = useCallback(
(taskId: string) => {
@@ -646,5 +650,6 @@ export function useSheetActions(workspaceId: string | null, onOpenChange: (open:
isArchiving,
handleArchiveConfirm,
...deleteActions,
...detachActions,
};
}
@@ -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}
/>
</ToastProvider>,
@@ -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(
<ToastProvider>
<MobileTaskList
tasks={[task("parent"), { ...task("child"), parentTaskId: "parent" }]}
workflows={[]}
stepsByWorkflowId={{}}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onArchiveTask={vi.fn()}
onDeleteTask={vi.fn()}
onDetachTask={onDetachTask}
deletingTaskId={null}
/>
</ToastProvider>,
);
const childRow = screen
.getByText("Task child")
.closest<HTMLElement>("[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");
});
});
@@ -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> | void;
onDetachTask: (taskId: string) => Promise<void> | 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 })}
/>
<TaskDetachTargetConfirmDialog
target={actions.detachingTask}
detachingTaskId={actions.detachingTaskId}
onDismiss={() => actions.setDetachingTask(null)}
onConfirm={actions.handleDetachConfirm}
/>
<SidebarLinkDialogs
actions={linking.actions}
repositories={linking.repositories}
@@ -0,0 +1,132 @@
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useEffect, type ReactNode } from "react";
import { StateProvider, useAppStore } from "@/components/state-provider";
import { TaskOptimisticContextProvider } from "@/hooks/use-optimistic-task-mutation";
import type { OfficeTask } from "@/lib/state/slices/office/types";
import type { Task } from "@/app/office/tasks/[id]/types";
import { ParentPicker } from "./parent-picker";
const detachTaskMock = vi.hoisted(() => 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<typeof import("@/lib/api/domains/kanban-api")>(
"@/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<typeof import("@/lib/api/domains/office-extended-api")>(
"@/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 (
<StateProvider>
<SeedTasks />
<TaskOptimisticContextProvider value={{ task, applyPatch: vi.fn(), restore: vi.fn() }}>
{children}
</TaskOptimisticContextProvider>
</StateProvider>
);
}
describe("ParentPicker", () => {
it("uses canonical detach after confirming No parent", async () => {
render(
<Wrapper>
<ParentPicker task={task} />
</Wrapper>,
);
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(
<Wrapper>
<ParentPicker task={task} />
</Wrapper>,
);
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();
});
});
@@ -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<WorkspaceMode | undefined>(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<OfficeTask[]>([]);
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 (
<Combobox
options={options}
value={currentValue}
onValueChange={handleSelect}
placeholder="No parent"
searchPlaceholder="Search tasks..."
emptyMessage="No tasks found."
triggerClassName="h-7 w-full justify-end px-2"
popoverAlign="end"
testId="parent-picker-trigger"
/>
<>
<Combobox
options={options}
value={currentValue}
onValueChange={handleSelect}
placeholder="No parent"
searchPlaceholder="Search tasks..."
emptyMessage="No tasks found."
disabled={isDetaching}
triggerClassName="h-7 w-full justify-end px-2"
popoverAlign="end"
testId="parent-picker-trigger"
/>
<TaskDetachConfirmDialog
open={detachRequested}
onOpenChange={setDetachRequested}
taskTitle={task.title}
sharesParentWorkspace={workspaceMode === "inherit_parent"}
isDetaching={isDetaching}
onConfirm={handleDetachConfirm}
/>
</>
);
}
@@ -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 (
<AlertDialog
open={open}
onOpenChange={(next) => {
if (!isDetaching) onOpenChange(next);
}}
>
<AlertDialogContent onClick={(event) => event.stopPropagation()}>
<AlertDialogHeader>
<AlertDialogTitle>Detach task from parent?</AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-2">
<p>
&quot;{taskTitle || "This task"}&quot; will become a top-level task. Its workflow,
subtasks, and state will not change.
</p>
<p>
Detaching changes the hierarchy only. Access to any shared workspace remains
unchanged.
</p>
{sharesParentWorkspace && (
<p className="font-medium text-foreground">
This task shares its parent&apos;s workspace. Current and future sessions will
keep using that shared workspace.
</p>
)}
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDetaching} className="cursor-pointer">
Cancel
</AlertDialogCancel>
<AlertDialogAction
disabled={isDetaching}
className="cursor-pointer"
data-testid="detach-task-confirm"
onClick={(event) => {
event.preventDefault();
if (!isDetaching) onConfirm();
}}
>
{isDetaching && <IconLoader className="mr-2 h-4 w-4 animate-spin" />}
Detach
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
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 (
<TaskDetachConfirmDialog
open={target !== null}
onOpenChange={(open) => {
if (!open) onDismiss();
}}
taskTitle={target?.title}
sharesParentWorkspace={target?.workspaceMode === "inherit_parent"}
isDetaching={target?.id === detachingTaskId}
onConfirm={onConfirm}
/>
);
}
@@ -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> | void;
detachingTask: DetachTarget;
setDetachingTask: (next: DetachTarget) => void;
detachingTaskId: string | null;
handleDetachConfirm: () => Promise<void> | 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}
/>
<TaskDetachTargetConfirmDialog
target={detachingTask}
detachingTaskId={detachingTaskId}
onDismiss={() => setDetachingTask(null)}
onConfirm={handleDetachConfirm}
/>
<SidebarLinkDialogs actions={actions} repositories={repositories} workspaceId={workspaceId} />
</>
);
@@ -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,
@@ -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,
};
}
@@ -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 (
<ContextMenuItem disabled={disabled} onSelect={() => onBulkArchive(actingIds)}>
<IconArchive className="mr-2 h-4 w-4" />
{count > 1 ? `Archive ${count} tasks` : "Archive"}
</ContextMenuItem>
);
}
if (!onArchiveTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onArchiveTask(taskId)}>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</ContextMenuItem>
);
}
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 (
<ContextMenuItem
data-testid="task-context-detach"
disabled={disabled}
onSelect={() => onDetachTask(task.id)}
>
<IconUnlink className="mr-2 h-4 w-4" />
Detach from parent
</ContextMenuItem>
);
}
export function TaskDeleteItem({
taskId,
isDeleting,
onDeleteTask,
}: {
taskId: string;
isDeleting?: boolean;
onDeleteTask?: (taskId: string) => void;
}) {
if (!onDeleteTask) return null;
return (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
onSelect={() => onDeleteTask(taskId)}
>
{isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
)}
Delete
</ContextMenuItem>
</>
);
}
@@ -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 (
<>
<TaskPinItem
@@ -249,6 +253,7 @@ function TaskContextMenuItems(props: TaskContextMenuItemsProps) {
closeMenu={closeMenu}
moveTasks={moveTasks}
/>
<TaskDetachItem task={task} disabled={isDeleting} onDetachTask={onDetach} />
<TaskDeleteItem taskId={task.id} isDeleting={isDeleting} onDeleteTask={onDelete} />
</>
);
@@ -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 (
<ContextMenuItem disabled={disabled} onSelect={() => onBulkArchive(actingIds)}>
<IconArchive className="mr-2 h-4 w-4" />
{n > 1 ? `Archive ${n} tasks` : "Archive"}
</ContextMenuItem>
);
}
if (!onArchiveTask) return null;
return (
<ContextMenuItem disabled={disabled} onSelect={() => onArchiveTask(taskId)}>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</ContextMenuItem>
);
}
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 (
<>
<ContextMenuSeparator />
<ContextMenuItem
variant="destructive"
disabled={isDeleting}
onSelect={() => onDeleteTask(taskId)}
>
{isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
)}
Delete
</ContextMenuItem>
</>
);
}
function TaskLinkMenu({
disabled,
onLinkPullRequest,
@@ -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(
<Providers>
<TaskSwitcher
grouped={grouped()}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onDetachTask={onDetachTask}
/>
</Providers>,
);
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(
<Providers>
<TaskSwitcher
grouped={grouped()}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onDetachTask={vi.fn()}
/>
</Providers>,
);
fireEvent.contextMenu(screen.getByText(ROOT.title));
expect(screen.queryByRole("menuitem", { name: "Detach from parent" })).toBeNull();
rerender(
<Providers>
<TaskSwitcher
grouped={grouped()}
activeTaskId={null}
selectedTaskId={null}
onSelectTask={vi.fn()}
onDetachTask={vi.fn()}
selectedTaskIds={new Set([CHILD.id, GRANDCHILD.id])}
/>
</Providers>,
);
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(
@@ -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}
+19 -8
View File
@@ -104,6 +104,8 @@ type CreateTaskOpts = {
plan_mode?: boolean;
metadata?: Record<string, unknown>;
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<string, unknown> {
const options = opts ?? {};
const body: Record<string, unknown> = {
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<string, unknown>;
/** 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<CreateTaskResponse> {
@@ -1220,6 +1229,8 @@ export class ApiClient {
primary_session_id?: string | null;
state?: string;
workflow_step_id?: string;
parent_id?: string;
metadata?: Record<string, unknown> | null;
repositories?: Array<{
id: string;
task_id: string;
@@ -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);
});
});
@@ -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",
});
});
});
+68
View File
@@ -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<Task>((resolve) => {
resolveRequest = resolve;
});
detachTaskMock.mockReturnValueOnce(request);
const { result } = renderHook(() => useDetachTask());
let first!: Promise<Task>;
let second!: Promise<Task>;
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<Task>((resolve) => {
resolveRequest = resolve;
});
detachTaskMock.mockReturnValueOnce(request);
const firstHook = renderHook(() => useDetachTask());
const secondHook = renderHook(() => useDetachTask());
let first!: Promise<Task>;
let second!: Promise<Task>;
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();
});
});
+87
View File
@@ -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<string, Promise<Task>>();
function requestDetachOnce(taskId: string): Promise<Task> {
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<string | null>(null);
const detachTask = useCallback((taskId: string): Promise<Task> => {
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<AppState>) {
const { detachTask, detachingTaskId } = useDetachTask();
const [detachingTask, setDetachingTask] = useState<DetachTarget | null>(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,
};
}
@@ -89,7 +89,7 @@ function toOfficeTaskPatch(patch: Partial<Task>): Partial<OfficeTask> {
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;
@@ -0,0 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { detachTask } from "./kanban-api";
const fetchSpy = vi.fn<typeof fetch>();
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();
});
});
+7
View File
@@ -118,6 +118,13 @@ export async function updateTask(
});
}
export async function detachTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}/detach`, {
...options,
init: { method: "POST", ...(options?.init ?? {}) },
});
}
export async function updateTaskRepositoryBaseBranch(
taskId: string,
taskRepositoryId: string,
+12
View File
@@ -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");
});
});
+15
View File
@@ -48,6 +48,20 @@ export type TaskLike = {
metadata?: Record<string, unknown> | 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<string, unknown>).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),
+2 -1
View File
@@ -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<AppState> {
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),
@@ -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;
+2
View File
@@ -91,6 +91,8 @@ export type TaskEventPayload = {
archived_at?: string | null;
updated_at?: string;
is_ephemeral: boolean;
parent_id?: string | null;
metadata?: Record<string, unknown> | null;
/** Deletion reason on task.deleted (e.g. "pr_approved_by_user"). Absent otherwise. */
reason?: string;
};
+102
View File
@@ -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.
@@ -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.
@@ -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.
@@ -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.
+6
View File
@@ -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.
+13 -2
View File
@@ -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"
]
},
+1
View File
@@ -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.
+1
View File
@@ -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 |
+91
View File
@@ -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).