feat: allow detaching subtasks from parent tasks (#1781)
This commit is contained in:
@@ -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 ©, 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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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>
|
||||
"{taskTitle || "This task"}" 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'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}
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user