feat: allow moving tasks across workflows (#822)

* feat: allow moving tasks across workflows

* fix: address PR review feedback

* fix: address remaining PR review feedback

* fix: remove unreachable move state event

* fix: map task move conflicts correctly

* fix: address PR review nitpicks

* fix: use neutral task move error message
This commit is contained in:
José Almeida
2026-05-05 14:13:43 +01:00
committed by GitHub
parent 3e676e3bb2
commit c64e835612
31 changed files with 2663 additions and 572 deletions
@@ -255,7 +255,10 @@ func (s *Service) executeStepTransition(ctx context.Context, taskID, sessionID s
zap.Bool("trigger_on_enter", triggerOnEnter))
if triggerOnEnter {
s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description)
// Automated transitions always clear review: the agent just completed
// a turn, so any pending review from a prior step is stale regardless
// of whether the new step has auto_start_agent.
s.finalizeStepEnter(ctx, taskID, sessionID, targetStep, task.Description, true)
} else {
// on_turn_start transitions: user is about to send a message, no on_enter needed.
// However, we still need to switch the agent profile if the target step requires
@@ -396,17 +399,20 @@ func (s *Service) processStepExitAndEnter(ctx context.Context, taskID string, se
return
}
s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription)
clearReview := targetStep.HasOnEnterAction(wfmodels.OnEnterAutoStartAgent)
s.finalizeStepEnter(ctx, taskID, session.ID, targetStep, taskDescription, clearReview)
}
// finalizeStepEnter clears review status, reloads the session, and processes on_enter
// actions for the target step. Shared by executeStepTransition and processStepExitAndEnter.
func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string) {
// Clear review status when moving to a new step
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
// finalizeStepEnter optionally clears review status, reloads the session, and
// processes on_enter actions for the target step. Shared by executeStepTransition
// and processStepExitAndEnter.
func (s *Service) finalizeStepEnter(ctx context.Context, taskID, sessionID string, targetStep *wfmodels.WorkflowStep, taskDescription string, clearReview bool) {
if clearReview {
if err := s.repo.UpdateSessionReviewStatus(ctx, sessionID, ""); err != nil {
s.logger.Warn("failed to clear session review status",
zap.String("session_id", sessionID),
zap.Error(err))
}
}
// Reload session after on_exit may have changed metadata
@@ -206,7 +206,7 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
})
})
t.Run("clears review status on step transition", func(t *testing.T) {
t.Run("preserves review status on manual move to non-auto-start step", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
@@ -235,9 +235,48 @@ func TestHandleTaskMovedWithSession(t *testing.T) {
synctest.Wait()
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.ReviewStatus == nil || *updated.ReviewStatus != "pending" {
t.Fatalf("expected pending review status to be preserved, got %#v", updated.ReviewStatus)
}
})
})
t.Run("clears review status on manual move to auto-start step", func(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
repo := setupTestRepo(t)
seedSession(t, repo, "t1", "s1", "step1")
_ = repo.UpdateSessionReviewStatus(ctx, "s1", "pending")
stepGetter := newMockStepGetter()
stepGetter.steps["step1"] = &wfmodels.WorkflowStep{
ID: "step1", WorkflowID: "wf1", Name: "Step 1", Position: 0,
Events: wfmodels.StepEvents{},
}
stepGetter.steps["step2"] = &wfmodels.WorkflowStep{
ID: "step2", WorkflowID: "wf1", Name: "Step 2", Position: 1,
Events: wfmodels.StepEvents{
OnEnter: []wfmodels.OnEnterAction{
{Type: wfmodels.OnEnterAutoStartAgent},
},
},
}
svc := createTestService(repo, stepGetter, newMockTaskRepo())
svc.handleTaskMovedWithSession(ctx, watcher.TaskMovedEventData{
TaskID: "t1",
SessionID: "s1",
FromStepID: "step1",
ToStepID: "step2",
TaskDescription: "test task",
})
synctest.Wait()
updated, _ := repo.GetTaskSession(ctx, "s1")
if updated.ReviewStatus != nil && *updated.ReviewStatus != "" {
t.Errorf("expected review status to be cleared, got %q", *updated.ReviewStatus)
t.Fatalf("expected review status to be cleared, got %q", *updated.ReviewStatus)
}
})
})
@@ -22,6 +22,20 @@ func handleNotFound(c *gin.Context, log *logger.Logger, err error, fallback stri
c.JSON(http.StatusInternalServerError, gin.H{"error": "request failed"})
}
func handleSelectedMoveError(c *gin.Context, log *logger.Logger, err error) {
switch {
case isNotFound(err):
c.JSON(http.StatusNotFound, gin.H{"error": "task or workflow not found"})
case isMoveConflict(err):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
case isValidationError(err):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
log.Error("task move failed", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "task move failed"})
}
}
func isNotFound(err error) bool {
if err == nil {
return false
@@ -29,6 +43,17 @@ func isNotFound(err error) bool {
return strings.Contains(strings.ToLower(err.Error()), "not found")
}
func isMoveConflict(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "active session") ||
strings.Contains(msg, "archived tasks cannot be moved") ||
strings.Contains(msg, "different workspace") ||
strings.Contains(msg, "does not belong to target workflow")
}
func isValidationError(err error) bool {
if err == nil {
return false
@@ -299,10 +299,11 @@ func (h *TaskHandlers) httpGetStepTaskCount(c *gin.Context) {
}
type httpBulkMoveTasksRequest struct {
SourceWorkflowID string `json:"source_workflow_id"`
SourceStepID string `json:"source_step_id,omitempty"`
TargetWorkflowID string `json:"target_workflow_id"`
TargetStepID string `json:"target_step_id"`
SourceWorkflowID string `json:"source_workflow_id"`
SourceStepID string `json:"source_step_id,omitempty"`
TargetWorkflowID string `json:"target_workflow_id"`
TargetStepID string `json:"target_step_id"`
TaskIDs []string `json:"task_ids,omitempty"`
}
func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
@@ -311,7 +312,15 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid payload"})
return
}
if body.SourceWorkflowID == "" || body.TargetWorkflowID == "" || body.TargetStepID == "" {
if body.TargetWorkflowID == "" || body.TargetStepID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "target_workflow_id and target_step_id are required"})
return
}
if len(body.TaskIDs) > 0 {
h.httpBulkMoveSelectedTasks(c, body)
return
}
if body.SourceWorkflowID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "source_workflow_id, target_workflow_id, and target_step_id are required"})
return
}
@@ -328,6 +337,20 @@ func (h *TaskHandlers) httpBulkMoveTasks(c *gin.Context) {
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
}
func (h *TaskHandlers) httpBulkMoveSelectedTasks(c *gin.Context, body httpBulkMoveTasksRequest) {
result, err := h.service.BulkMoveSelectedTasks(
c.Request.Context(),
body.TaskIDs,
body.TargetWorkflowID,
body.TargetStepID,
)
if err != nil {
handleSelectedMoveError(c, h.logger, err)
return
}
c.JSON(http.StatusOK, dto.BulkMoveTasksResponse{MovedCount: result.MovedCount})
}
type httpTaskRepositoryInput struct {
RepositoryID string `json:"repository_id"`
BaseBranch string `json:"base_branch"`
@@ -850,7 +873,7 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
body.WorkflowID, body.WorkflowStepID, body.Position,
)
if err != nil {
handleNotFound(c, h.logger, err, "task not moved")
handleSelectedMoveError(c, h.logger, err)
return
}
@@ -2,14 +2,21 @@ package handlers
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/service"
)
func newTestLogger(t *testing.T) *logger.Logger {
@@ -25,6 +32,132 @@ func newTestLogger(t *testing.T) *logger.Logger {
return log
}
func TestHandleSelectedMoveError(t *testing.T) {
gin.SetMode(gin.TestMode)
log := newTestLogger(t)
tests := []struct {
name string
err error
want int
wantBodyContains string
}{
{
name: "not found",
err: errors.New("task not found: task-1"),
want: http.StatusNotFound,
},
{
name: "move conflict",
err: errors.New("task task-1 cannot be moved: task has an active session (running)"),
want: http.StatusConflict,
},
{
name: "bad request validation",
err: errors.New("invalid workflow id"),
want: http.StatusBadRequest,
},
{
name: "internal",
err: errors.New("failed to count target workflow step tasks: database is locked"),
want: http.StatusInternalServerError,
wantBodyContains: "task move failed",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
handleSelectedMoveError(c, log, tc.err)
assert.Equal(t, tc.want, rec.Code)
if tc.wantBodyContains != "" {
assert.Contains(t, rec.Body.String(), tc.wantBodyContains)
}
})
}
}
type moveTaskConflictRepo struct {
mockRepository
task *models.Task
sessions []*models.TaskSession
}
func (m *moveTaskConflictRepo) GetTask(ctx context.Context, id string) (*models.Task, error) {
return m.task, nil
}
func (m *moveTaskConflictRepo) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) {
return m.sessions, nil
}
func TestHTTPMoveTaskMapsMoveConflict(t *testing.T) {
gin.SetMode(gin.TestMode)
log := newTestLogger(t)
archivedAt := time.Now().UTC()
tests := []struct {
name string
task *models.Task
sessions []*models.TaskSession
}{
{
name: "archived task",
task: &models.Task{
ID: "task-archived",
WorkspaceID: "workspace-1",
WorkflowID: "wf-source",
WorkflowStepID: "step-source",
ArchivedAt: &archivedAt,
},
},
{
name: "active session",
task: &models.Task{
ID: "task-running",
WorkspaceID: "workspace-1",
WorkflowID: "wf-source",
WorkflowStepID: "step-source",
},
sessions: []*models.TaskSession{{
ID: "session-running",
TaskID: "task-running",
State: models.TaskSessionStateRunning,
}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
repo := &moveTaskConflictRepo{task: tc.task, sessions: tc.sessions}
svc := service.NewService(service.Repos{
Workspaces: repo, Tasks: repo, TaskRepos: repo,
Workflows: repo, Messages: repo, Turns: repo,
Sessions: repo, GitSnapshots: repo, RepoEntities: repo,
Executors: repo, Environments: repo, TaskEnvironments: repo,
Reviews: repo,
}, nil, log, service.RepositoryDiscoveryConfig{})
h := &TaskHandlers{service: svc, logger: log}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Params = gin.Params{{Key: "id", Value: tc.task.ID}}
c.Request = httptest.NewRequest(http.MethodPost, "/tasks/"+tc.task.ID+"/move", strings.NewReader(`{
"workflow_id": "wf-target",
"workflow_step_id": "step-target",
"position": 0
}`))
c.Request.Header.Set("Content-Type", "application/json")
h.httpMoveTask(c)
assert.Equal(t, http.StatusConflict, rec.Code)
})
}
}
func TestResolveFreshBranchName(t *testing.T) {
tests := []struct {
name string
@@ -22,7 +22,7 @@ func (s *Service) PublishTaskUpdated(ctx context.Context, task *models.Task) {
}
// publishTaskEvent publishes task events to the event bus
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState) {
func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *models.Task, oldState *v1.TaskState, oldWorkflowIDs ...string) {
if s.eventBus == nil {
return
}
@@ -66,6 +66,9 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task *
data["old_state"] = string(*oldState)
data["new_state"] = string(task.State)
}
if len(oldWorkflowIDs) > 0 && oldWorkflowIDs[0] != "" && oldWorkflowIDs[0] != task.WorkflowID {
data["old_workflow_id"] = oldWorkflowIDs[0]
}
event := bus.NewEvent(eventType, "task-service", data)
if err := s.eventBus.Publish(ctx, eventType, event); err != nil {
@@ -154,12 +157,14 @@ func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]inte
// publishTaskMovedEvent publishes a task.moved event so the orchestrator can process
// on_exit/on_enter actions for the new workflow step.
func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromStepID, toStepID, sessionID string) {
func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) {
if s.eventBus == nil {
return
}
data := map[string]interface{}{
"task_id": task.ID,
"from_workflow_id": fromWorkflowID,
"to_workflow_id": task.WorkflowID,
"from_step_id": fromStepID,
"to_step_id": toStepID,
"session_id": sessionID,
@@ -228,14 +228,14 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
return nil, err
}
if err := s.checkMoveTaskApproval(ctx, id, task.WorkflowStepID, workflowStepID); err != nil {
if err := s.validateTaskMove(ctx, task, workflowID, workflowStepID); err != nil {
return nil, err
}
oldWorkflowID := task.WorkflowID
oldStepID := task.WorkflowStepID
stepChanged := oldStepID != workflowStepID
oldState := task.State
task.WorkflowID = workflowID
task.WorkflowStepID = workflowStepID
task.Position = position
@@ -252,16 +252,11 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
sessionID = activeSession.ID
}
// Publish state_changed event if state changed, otherwise just updated
if oldState != task.State {
s.publishTaskEvent(ctx, events.TaskStateChanged, task, &oldState)
} else {
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
}
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID)
// Publish task.moved event so the orchestrator can process on_exit/on_enter actions
if stepChanged {
s.publishTaskMovedEvent(ctx, task, oldStepID, workflowStepID, sessionID)
s.publishTaskMovedEvent(ctx, task, oldWorkflowID, oldStepID, workflowStepID, sessionID)
}
s.logger.Info("task moved",
@@ -288,22 +283,51 @@ func (s *Service) MoveTask(ctx context.Context, id string, workflowID string, wo
return result, nil
}
// checkMoveTaskApproval returns an error when the task's primary session has a pending
// review and the caller is trying to move it to a different step.
func (s *Service) checkMoveTaskApproval(ctx context.Context, taskID, currentStepID, targetStepID string) error {
if currentStepID == targetStepID {
func (s *Service) validateTaskMove(ctx context.Context, task *models.Task, workflowID, workflowStepID string) error {
if task.ArchivedAt != nil {
return fmt.Errorf("archived tasks cannot be moved")
}
if err := s.validateMoveSessions(ctx, task.ID); err != nil {
return err
}
targetWorkflow, err := s.workflows.GetWorkflow(ctx, workflowID)
if err != nil {
return fmt.Errorf("failed to get target workflow: %w", err)
}
if targetWorkflow.WorkspaceID != task.WorkspaceID {
return fmt.Errorf("target workflow is in a different workspace")
}
if s.workflowStepGetter == nil {
return nil
}
primarySession, err := s.sessions.GetPrimarySessionByTaskID(ctx, taskID)
if err != nil || primarySession == nil {
return nil
targetStep, err := s.workflowStepGetter.GetStep(ctx, workflowStepID)
if err != nil {
return fmt.Errorf("failed to get target workflow step: %w", err)
}
if primarySession.ReviewStatus != nil && *primarySession.ReviewStatus == "pending" {
return fmt.Errorf("task is pending approval - use Approve button to proceed or send a message to request changes")
if targetStep.WorkflowID != workflowID {
return fmt.Errorf("target workflow step does not belong to target workflow")
}
return nil
}
func (s *Service) validateMoveSessions(ctx context.Context, taskID string) error {
sessions, err := s.sessions.ListTaskSessions(ctx, taskID)
if err != nil {
return fmt.Errorf("failed to list task sessions: %w", err)
}
for _, session := range sessions {
if isSessionMoveBlocked(session.State) {
return fmt.Errorf("task has an active session (%s)", session.State)
}
}
return nil
}
func isSessionMoveBlocked(state models.TaskSessionState) bool {
return state == models.TaskSessionStateStarting ||
state == models.TaskSessionStateRunning
}
// resolvePrimaryOrActiveSession returns the primary session if it is in an active
// state, otherwise falls back to the most recently started active session.
func (s *Service) resolvePrimaryOrActiveSession(ctx context.Context, taskID string) *models.TaskSession {
@@ -340,6 +364,74 @@ type BulkMoveTasksResult struct {
MovedCount int
}
// BulkMoveSelectedTasks moves an explicit task list to a target workflow step.
// The list order is treated as the visible UI order; tasks already in the
// target step are skipped. Validation reads tasks one at a time because the UI
// sends small selected batches; the move is not transactional if task state
// changes between pre-validation and an individual MoveTask call.
func (s *Service) BulkMoveSelectedTasks(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) {
ids := uniqueTaskIDs(taskIDs)
if len(ids) == 0 {
return &BulkMoveTasksResult{MovedCount: 0}, nil
}
tasks, err := s.validateSelectedMoveBatch(ctx, ids, targetWorkflowID, targetStepID)
if err != nil {
return nil, err
}
nextPosition, err := s.tasks.CountTasksByWorkflowStep(ctx, targetStepID)
if err != nil {
return nil, fmt.Errorf("failed to count target workflow step tasks: %w", err)
}
movedCount := 0
for _, task := range tasks {
if task.WorkflowID == targetWorkflowID && task.WorkflowStepID == targetStepID {
continue
}
if _, err := s.MoveTask(ctx, task.ID, targetWorkflowID, targetStepID, nextPosition+movedCount); err != nil {
return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err)
}
movedCount++
}
return &BulkMoveTasksResult{MovedCount: movedCount}, nil
}
func uniqueTaskIDs(taskIDs []string) []string {
seen := make(map[string]struct{}, len(taskIDs))
result := make([]string, 0, len(taskIDs))
for _, id := range taskIDs {
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func (s *Service) validateSelectedMoveBatch(ctx context.Context, taskIDs []string, targetWorkflowID, targetStepID string) ([]*models.Task, error) {
tasks := make([]*models.Task, 0, len(taskIDs))
for _, id := range taskIDs {
task, err := s.tasks.GetTask(ctx, id)
if err != nil {
return nil, err
}
if task.WorkflowID != targetWorkflowID || task.WorkflowStepID != targetStepID {
if err := s.validateTaskMove(ctx, task, targetWorkflowID, targetStepID); err != nil {
return nil, fmt.Errorf("task %s cannot be moved: %w", id, err)
}
}
tasks = append(tasks, task)
}
return tasks, nil
}
// BulkMoveTasks moves all tasks from a source workflow/step to a target workflow/step.
// If sourceStepID is empty, all tasks in the source workflow are moved.
func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceStepID, targetWorkflowID, targetStepID string) (*BulkMoveTasksResult, error) {
@@ -361,6 +453,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte
now := time.Now().UTC()
for i, task := range tasks {
oldWorkflowID := task.WorkflowID
task.WorkflowID = targetWorkflowID
task.WorkflowStepID = targetStepID
task.Position = i
@@ -373,7 +466,7 @@ func (s *Service) BulkMoveTasks(ctx context.Context, sourceWorkflowID, sourceSte
return nil, fmt.Errorf("failed to move task %s: %w", task.ID, err)
}
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil)
s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowID)
}
s.logger.Info("bulk moved tasks",
@@ -0,0 +1,339 @@
package service
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/events"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/task/models"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
)
type fakeWorkflowStepGetter struct {
steps map[string]*wfmodels.WorkflowStep
}
func (f *fakeWorkflowStepGetter) GetStep(_ context.Context, stepID string) (*wfmodels.WorkflowStep, error) {
if step, ok := f.steps[stepID]; ok {
return step, nil
}
return nil, errStepNotFoundForTest
}
func (f *fakeWorkflowStepGetter) GetNextStepByPosition(_ context.Context, workflowID string, currentPosition int) (*wfmodels.WorkflowStep, error) {
for _, step := range f.steps {
if step.WorkflowID == workflowID && step.Position == currentPosition+1 {
return step, nil
}
}
return nil, nil
}
type testStepNotFound struct{}
func (testStepNotFound) Error() string { return "step not found" }
var errStepNotFoundForTest = testStepNotFound{}
func TestService_MoveTaskRejectsInvalidWorkflowTargets(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
tests := []struct {
name string
taskID string
targetWF string
targetSt string
}{
{
name: "step belongs to another workflow",
taskID: "task-invalid-step",
targetWF: "wf-source",
targetSt: "step-target",
},
{
name: "workflow belongs to another workspace",
taskID: "task-other-workspace",
targetWF: "wf-other-workspace",
targetSt: "step-other-workspace",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
createMoveTask(t, ctx, repo, tt.taskID, "wf-source", "step-source", nil)
_, err := svc.MoveTask(ctx, tt.taskID, tt.targetWF, tt.targetSt, 0)
if err == nil {
t.Fatalf("expected move to be rejected")
}
task, err := repo.GetTask(ctx, tt.taskID)
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" {
t.Fatalf("task moved despite validation error: workflow=%s step=%s", task.WorkflowID, task.WorkflowStepID)
}
})
}
}
func TestService_MoveTaskAllowsPendingReviewWhenSessionIdle(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-pending-review", "wf-source", "step-source", nil)
pending := "pending"
createMoveSession(t, ctx, repo, "session-pending-review", "task-pending-review", models.TaskSessionStateWaitingForInput, &pending)
moved, err := svc.MoveTask(ctx, "task-pending-review", "wf-source", "step-review-target", 0)
if err != nil {
t.Fatalf("pending review on idle session should not block manual move: %v", err)
}
if moved.Task.WorkflowStepID != "step-review-target" {
t.Fatalf("expected step-review-target, got %s", moved.Task.WorkflowStepID)
}
}
func TestService_MoveTaskRejectsRunningSession(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-running", "wf-source", "step-source", nil)
createMoveSession(t, ctx, repo, "session-running", "task-running", models.TaskSessionStateRunning, nil)
_, err := svc.MoveTask(ctx, "task-running", "wf-source", "step-review-target", 0)
if err == nil {
t.Fatalf("expected running session move to be rejected")
}
task, err := repo.GetTask(ctx, "task-running")
if err != nil {
t.Fatalf("GetTask: %v", err)
}
if task.WorkflowStepID != "step-source" {
t.Fatalf("task moved despite running session: %s", task.WorkflowStepID)
}
}
func TestService_MoveTaskRejectsArchivedTask(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
now := time.Now().UTC()
createMoveTask(t, ctx, repo, "task-archived", "wf-source", "step-source", &now)
_, err := svc.MoveTask(ctx, "task-archived", "wf-source", "step-review-target", 0)
if err == nil {
t.Fatalf("expected archived task move to be rejected")
}
}
func TestService_MoveTaskMovedEventIncludesSourceWorkflow(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-cross-workflow", "wf-source", "step-source", nil)
eventBus.ClearEvents()
_, err := svc.MoveTask(ctx, "task-cross-workflow", "wf-target", "step-target", 0)
if err != nil {
t.Fatalf("MoveTask: %v", err)
}
updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated)
updatedData, ok := updatedEvent.Data.(map[string]interface{})
if !ok {
t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data)
}
if got := updatedData["old_workflow_id"]; got != "wf-source" {
t.Fatalf("old_workflow_id = %v, want wf-source", got)
}
event := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskMoved)
data, ok := event.Data.(map[string]interface{})
if !ok {
t.Fatalf("event data type = %T, want map[string]interface{}", event.Data)
}
if got := data["from_workflow_id"]; got != "wf-source" {
t.Fatalf("from_workflow_id = %v, want wf-source", got)
}
if got := data["to_workflow_id"]; got != "wf-target" {
t.Fatalf("to_workflow_id = %v, want wf-target", got)
}
}
func TestService_BulkMoveTasksUpdatedEventIncludesSourceWorkflow(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
createMoveTask(t, ctx, repo, "task-bulk-cross-workflow", "wf-source", "step-source", nil)
eventBus.ClearEvents()
_, err := svc.BulkMoveTasks(ctx, "wf-source", "", "wf-target", "step-target")
if err != nil {
t.Fatalf("BulkMoveTasks: %v", err)
}
updatedEvent := findPublishedEvent(t, eventBus.GetPublishedEvents(), events.TaskUpdated)
updatedData, ok := updatedEvent.Data.(map[string]interface{})
if !ok {
t.Fatalf("updated event data type = %T, want map[string]interface{}", updatedEvent.Data)
}
if got := updatedData["old_workflow_id"]; got != "wf-source" {
t.Fatalf("old_workflow_id = %v, want wf-source", got)
}
}
func TestService_BulkMoveSelectedTasksValidatesBatchBeforeMoving(t *testing.T) {
svc, _, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-batch-ok", "wf-source", "step-source", nil)
createMoveTask(t, ctx, repo, "task-batch-running", "wf-source", "step-source", nil)
createMoveSession(t, ctx, repo, "session-batch-running", "task-batch-running", models.TaskSessionStateRunning, nil)
_, err := svc.BulkMoveSelectedTasks(ctx, []string{"task-batch-ok", "task-batch-running"}, "wf-target", "step-target")
if err == nil {
t.Fatalf("expected selected batch move to be rejected")
}
for _, id := range []string{"task-batch-ok", "task-batch-running"} {
task, err := repo.GetTask(ctx, id)
if err != nil {
t.Fatalf("GetTask(%s): %v", id, err)
}
if task.WorkflowID != "wf-source" || task.WorkflowStepID != "step-source" {
t.Fatalf("%s moved despite rejected batch: workflow=%s step=%s", id, task.WorkflowID, task.WorkflowStepID)
}
}
}
func TestService_BulkMoveSelectedTasksSkipsCurrentTargetAndAppendsInOrder(t *testing.T) {
svc, eventBus, repo := createTestService(t)
ctx := context.Background()
seedMoveWorkflows(t, ctx, repo)
seedMoveSteps(svc)
createMoveTask(t, ctx, repo, "task-target-existing", "wf-target", "step-target", nil)
createMoveTask(t, ctx, repo, "task-source-a", "wf-source", "step-source", nil)
createMoveTask(t, ctx, repo, "task-target-already", "wf-target", "step-target", nil)
createMoveTask(t, ctx, repo, "task-source-b", "wf-source", "step-source", nil)
eventBus.ClearEvents()
result, err := svc.BulkMoveSelectedTasks(
ctx,
[]string{"task-source-a", "task-target-already", "task-source-b"},
"wf-target",
"step-target",
)
if err != nil {
t.Fatalf("BulkMoveSelectedTasks: %v", err)
}
if result.MovedCount != 2 {
t.Fatalf("MovedCount = %d, want 2", result.MovedCount)
}
sourceA, err := repo.GetTask(ctx, "task-source-a")
if err != nil {
t.Fatalf("GetTask(task-source-a): %v", err)
}
sourceB, err := repo.GetTask(ctx, "task-source-b")
if err != nil {
t.Fatalf("GetTask(task-source-b): %v", err)
}
if sourceA.Position != 2 || sourceB.Position != 3 {
t.Fatalf("positions = (%d, %d), want (2, 3)", sourceA.Position, sourceB.Position)
}
movedEvents := 0
for _, event := range eventBus.GetPublishedEvents() {
if event.Type == events.TaskMoved {
movedEvents++
}
}
if movedEvents != 2 {
t.Fatalf("task.moved events = %d, want 2", movedEvents)
}
}
func seedMoveWorkflows(t *testing.T, ctx context.Context, repo interface {
CreateWorkspace(context.Context, *models.Workspace) error
CreateWorkflow(context.Context, *models.Workflow) error
}) {
t.Helper()
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}))
must(t, repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}))
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-source", WorkspaceID: "ws-1", Name: "Source"}))
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-target", WorkspaceID: "ws-1", Name: "Target"}))
must(t, repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-other-workspace", WorkspaceID: "ws-2", Name: "Other"}))
}
func seedMoveSteps(svc *Service) {
svc.SetWorkflowStepGetter(&fakeWorkflowStepGetter{steps: map[string]*wfmodels.WorkflowStep{
"step-source": {ID: "step-source", WorkflowID: "wf-source", Name: "Source", Position: 0},
"step-review-target": {ID: "step-review-target", WorkflowID: "wf-source", Name: "Review", Position: 1},
"step-target": {ID: "step-target", WorkflowID: "wf-target", Name: "Target", Position: 0},
"step-other-workspace": {ID: "step-other-workspace", WorkflowID: "wf-other-workspace", Name: "Other", Position: 0},
}})
}
func createMoveTask(t *testing.T, ctx context.Context, repo interface {
CreateTask(context.Context, *models.Task) error
ArchiveTask(context.Context, string) error
}, id, workflowID, stepID string, archivedAt *time.Time) {
t.Helper()
must(t, repo.CreateTask(ctx, &models.Task{
ID: id,
WorkspaceID: "ws-1",
WorkflowID: workflowID,
WorkflowStepID: stepID,
Title: id,
State: v1.TaskStateTODO,
ArchivedAt: archivedAt,
}))
if archivedAt != nil {
must(t, repo.ArchiveTask(ctx, id))
}
}
func createMoveSession(t *testing.T, ctx context.Context, repo interface {
CreateTaskSession(context.Context, *models.TaskSession) error
}, id, taskID string, state models.TaskSessionState, reviewStatus *string) {
t.Helper()
must(t, repo.CreateTaskSession(ctx, &models.TaskSession{
ID: id,
TaskID: taskID,
State: state,
IsPrimary: true,
ReviewStatus: reviewStatus,
}))
}
func findPublishedEvent(t *testing.T, published []*bus.Event, eventType string) *bus.Event {
t.Helper()
for _, event := range published {
if event.Type == eventType {
return event
}
}
t.Fatalf("event %s not published; got %d events", eventType, len(published))
return nil
}
func must(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
}
+2 -1
View File
@@ -12,7 +12,8 @@ import {
useSensors,
} from "@dnd-kit/core";
import { KanbanColumn, WorkflowStep } from "./kanban-column";
import { KanbanCardPreview, Task } from "./kanban-card";
import { Task } from "./kanban-card";
import { KanbanCardPreview } from "./kanban-card-preview";
import { MobileColumnTabs } from "./kanban/mobile-column-tabs";
import { SwipeableColumns } from "./kanban/swipeable-columns";
import { MobileDropTargets } from "./kanban/mobile-drop-targets";
+27 -8
View File
@@ -69,14 +69,11 @@ function useWorkflowSelection({
(workflow: WorkflowsState["items"][number]) => workflow.workspaceId === workspaceId,
);
const hasSettingsWorkflow =
settings.workflowId &&
workspaceWorkflows.some(
(workflow: WorkflowsState["items"][number]) => workflow.id === settings.workflowId,
);
const fallbackWorkflowId = workspaceWorkflows.length === 1 ? workspaceWorkflows[0].id : null;
const desiredWorkflowId =
(hasSettingsWorkflow ? settings.workflowId : fallbackWorkflowId) ?? null;
const desiredWorkflowId = resolveDesiredWorkflowId({
activeWorkflowId: workflowsState.activeId,
settingsWorkflowId: settings.workflowId,
workspaceWorkflows,
});
setActiveWorkflow(desiredWorkflowId);
if (!desiredWorkflowId) {
store.getState().hydrate({
@@ -94,6 +91,28 @@ function useWorkflowSelection({
]);
}
function resolveDesiredWorkflowId({
activeWorkflowId,
settingsWorkflowId,
workspaceWorkflows,
}: {
activeWorkflowId?: string | null;
settingsWorkflowId?: string | null;
workspaceWorkflows: WorkflowsState["items"];
}): string | null {
const visibleWorkflows = workspaceWorkflows.filter((workflow) => !workflow.hidden);
if (activeWorkflowId && visibleWorkflows.some((workflow) => workflow.id === activeWorkflowId)) {
return activeWorkflowId;
}
if (
settingsWorkflowId &&
visibleWorkflows.some((workflow) => workflow.id === settingsWorkflowId)
) {
return settingsWorkflowId;
}
return visibleWorkflows[0]?.id ?? null;
}
function useMoveErrorState(router: ReturnType<typeof useRouter>) {
const [moveError, setMoveError] = useState<MoveTaskError | null>(null);
+384
View File
@@ -0,0 +1,384 @@
"use client";
import { useState } from "react";
import { CSS, type Transform } from "@dnd-kit/utilities";
import type { DraggableAttributes, DraggableSyntheticListeners } from "@dnd-kit/core";
import { IconAlertCircle, IconArrowsMaximize, IconDots, IconSubtask } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Card, CardContent } from "@kandev/ui/card";
import { Checkbox } from "@kandev/ui/checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuTrigger } from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import {
KanbanCardDropdownMenuItems,
type KanbanCardMenuEntry,
} from "@/components/kanban-card-menu-items";
import { useAppStore } from "@/components/state-provider";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
import { cn } from "@/lib/utils";
import { needsAction } from "@/lib/utils/needs-action";
import type { Task } from "@/components/kanban-card";
type KanbanCardActionProps = {
task: Task;
showMaximizeButton?: boolean;
onOpenFullPage?: (task: Task) => void;
menuEntries: KanbanCardMenuEntry[];
isDeleting?: boolean;
isArchiving?: boolean;
};
type DraggableCardState = {
attributes: DraggableAttributes;
listeners: DraggableSyntheticListeners;
setNodeRef: (element: HTMLElement | null) => void;
transform: Transform | null;
isDragging: boolean;
};
export type KanbanCardShellProps = KanbanCardActionProps &
DraggableCardState & {
repositoryNames?: string[];
isSelected?: boolean;
isMultiSelectMode?: boolean;
isPreviewed: boolean;
onClick: () => void;
onCheckboxClick: (e: React.MouseEvent) => void;
};
const REPO_CHIPS_VISIBLE = 2;
function RepoChipRow({ repoNames }: { repoNames: string[] }) {
if (repoNames.length === 0) return null;
const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE);
const overflow = repoNames.slice(REPO_CHIPS_VISIBLE);
const row = (
<div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden">
{visible.map((name) => (
<span
key={name}
className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate"
>
{name}
</span>
))}
{overflow.length > 0 && (
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80">
+{overflow.length}
</span>
)}
</div>
);
if (repoNames.length <= 1) return row;
return (
<Tooltip>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="top" align="start">
<div className="flex flex-col gap-0.5 text-xs">
{repoNames.map((name) => (
<span key={name}>{name}</span>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
export function KanbanCardBody({
task,
repoNames,
actions,
}: {
task: Task;
repoNames: string[];
actions?: React.ReactNode;
}) {
return (
<>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<RepoChipRow repoNames={repoNames} />
<div className="flex items-center gap-1 min-w-0">
<p
data-testid="task-card-title"
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
>
{task.title}
</p>
<PRTaskIcon taskId={task.id} />
</div>
</div>
{task.isRemoteExecutor && (
<RemoteCloudTooltip
taskId={task.id}
sessionId={task.primarySessionId ?? null}
fallbackName={task.primaryExecutorName ?? task.primaryExecutorType}
/>
)}
{actions}
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1">
{task.description}
</p>
)}
<KanbanCardBadges task={task} />
</>
);
}
function KanbanCardBadges({ task }: { task: Task }) {
const parentTitle = useAppStore((s) => {
if (!task.parentTaskId) return null;
return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null;
});
const showRow =
(task.sessionCount && task.sessionCount > 1) ||
task.reviewStatus === "changes_requested" ||
task.reviewStatus === "pending" ||
task.parentTaskId;
if (!showRow) return null;
return (
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
{task.parentTaskId && (
<Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0">
<IconSubtask className="h-3 w-3 shrink-0" />
<span className="truncate">{parentTitle ?? "Subtask"}</span>
</Badge>
)}
{task.sessionCount && task.sessionCount > 1 && (
<Badge variant="secondary" className="text-xs h-5">
{task.sessionCount} sessions
</Badge>
)}
{task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-600">
<IconAlertCircle className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium">Approval Required</span>
</div>
)}
{task.reviewStatus === "changes_requested" && (
<Badge
variant="outline"
className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5"
>
Changes Requested
</Badge>
)}
</div>
);
}
function KanbanCardActions({
task,
showMaximizeButton,
onOpenFullPage,
menuEntries,
isDeleting,
isArchiving,
}: KanbanCardActionProps) {
const [menuOpen, setMenuOpen] = useState(false);
const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving);
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest);
const hasKnownSession =
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
return (
<div className="flex items-center gap-2">
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) &&
statusIcon}
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onOpenFullPage(task);
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label="Open full page"
title="Open full page"
>
<IconArrowsMaximize className="h-4 w-4" />
</button>
)}
<KanbanCardMenu
task={task}
effectiveMenuOpen={effectiveMenuOpen}
setMenuOpen={setMenuOpen}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={menuEntries}
/>
</div>
);
}
type KanbanCardMenuProps = KanbanCardActionProps & {
effectiveMenuOpen: boolean;
setMenuOpen: (open: boolean) => void;
};
function KanbanCardMenu(props: KanbanCardMenuProps) {
const { effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props;
const { menuEntries } = props;
const isProcessing = isDeleting || isArchiving;
return (
<DropdownMenu
open={effectiveMenuOpen}
onOpenChange={(open) => {
if (!open && isProcessing) return;
setMenuOpen(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
aria-label="More options"
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<KanbanCardDropdownMenuItems entries={menuEntries} />
</DropdownMenuContent>
</DropdownMenu>
);
}
function KanbanCardCheckbox({
taskId,
taskTitle,
isSelected,
onCheckboxClick,
}: {
taskId: string;
taskTitle: string;
isSelected?: boolean;
onCheckboxClick: (e: React.MouseEvent) => void;
}) {
return (
<div
className="mt-0.5 shrink-0"
onClick={onCheckboxClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`task-select-checkbox-${taskId}`}
>
<Checkbox
checked={!!isSelected}
aria-label={`Select task ${taskTitle}`}
className="cursor-pointer border-muted-foreground/50"
/>
</div>
);
}
function KanbanCardActionSlot({
isMultiSelectMode,
task,
showMaximizeButton,
onOpenFullPage,
menuEntries,
isDeleting,
isArchiving,
}: KanbanCardActionProps & { isMultiSelectMode?: boolean }) {
if (isMultiSelectMode) return null;
return (
<KanbanCardActions
task={task}
showMaximizeButton={showMaximizeButton}
onOpenFullPage={onOpenFullPage}
menuEntries={menuEntries}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
);
}
export function KanbanCardShell({
task,
repositoryNames,
attributes,
listeners,
setNodeRef,
transform,
isDragging,
isPreviewed,
isSelected,
isMultiSelectMode,
showMaximizeButton,
isDeleting,
isArchiving,
onClick,
onCheckboxClick,
onOpenFullPage,
menuEntries,
}: KanbanCardShellProps) {
const showCheckbox = isMultiSelectMode || !!isSelected;
const style = {
transform: CSS.Translate.toString(transform),
transition: "none",
willChange: isDragging ? "transform" : undefined,
};
return (
<Card
size="sm"
ref={setNodeRef}
style={style}
data-testid={`task-card-${task.id}`}
className={cn(
"group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
needsAction(task) && !isSelected && "border-l-2 border-l-amber-500",
isDragging && "opacity-50 z-50",
isSelected && "ring-1 ring-primary/60 border-primary/60",
isPreviewed && !isSelected && "ring-2 ring-primary border-primary",
)}
onClick={onClick}
{...(!isMultiSelectMode ? listeners : {})}
{...(!isMultiSelectMode ? attributes : {})}
>
<CardContent className="px-2 py-1">
<div className="flex items-start gap-1.5">
{showCheckbox && (
<KanbanCardCheckbox
taskId={task.id}
taskTitle={task.title}
isSelected={isSelected}
onCheckboxClick={onCheckboxClick}
/>
)}
<div className="min-w-0 flex-1">
<KanbanCardBody
task={task}
repoNames={repositoryNames ?? []}
actions={
<KanbanCardActionSlot
isMultiSelectMode={isMultiSelectMode}
task={task}
showMaximizeButton={showMaximizeButton}
onOpenFullPage={onOpenFullPage}
menuEntries={menuEntries}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
}
/>
</div>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,29 @@
"use client";
import { ContextMenu, ContextMenuContent, ContextMenuTrigger } from "@kandev/ui/context-menu";
import {
KanbanCardContextMenuItems,
type KanbanCardMenuEntry,
} from "@/components/kanban-card-menu-items";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
export function KanbanCardContextMenu({
entries,
children,
}: {
entries: KanbanCardMenuEntry[];
children: React.ReactNode;
}) {
const { isDesktop } = useResponsiveBreakpoint();
if (!isDesktop) return children;
return (
<ContextMenu>
<ContextMenuTrigger className="block">{children}</ContextMenuTrigger>
<ContextMenuContent className="w-56">
<KanbanCardContextMenuItems entries={entries} />
</ContextMenuContent>
</ContextMenu>
);
}
@@ -0,0 +1,436 @@
"use client";
import { useMemo, type ReactNode } from "react";
import {
IconArchive,
IconArrowRight,
IconLoader,
IconLogicBuffer,
IconPencil,
IconTrash,
} from "@tabler/icons-react";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@kandev/ui/context-menu";
import {
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
} from "@kandev/ui/dropdown-menu";
import { useAppStore } from "@/components/state-provider";
import type { WorkflowStep } from "@/components/kanban-card";
import {
stepHasAutoStart,
type TaskMoveStep,
type TaskMoveWorkflow,
} from "@/components/task/task-move-context-menu";
import { cn } from "@/lib/utils";
type ItemEntry = {
kind: "item";
key: string;
label: ReactNode;
icon?: ReactNode;
leading?: ReactNode;
trailing?: ReactNode;
disabled?: boolean;
destructive?: boolean;
testId?: string;
onSelect?: () => void;
};
type SeparatorEntry = { kind: "separator"; key: string };
type SubmenuEntry = {
kind: "submenu";
key: string;
label: ReactNode;
icon?: ReactNode;
disabled?: boolean;
testId?: string;
className?: string;
children: KanbanCardMenuEntry[];
};
export type KanbanCardMenuEntry = ItemEntry | SeparatorEntry | SubmenuEntry;
export type KanbanCardMoveTargets = {
currentWorkflowId: string | null;
workflowItems: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
};
type BuildKanbanCardMenuEntriesArgs = {
currentWorkflowId?: string | null;
currentStepId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
isDeleting?: boolean;
isArchiving?: boolean;
onEdit?: () => void;
onArchive?: () => void;
onDelete?: () => void;
onMoveToStep?: (stepId: string) => void;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
};
function StepBadges({ step, isCurrent }: { step: TaskMoveStep; isCurrent: boolean }) {
const hasAutoStart = stepHasAutoStart(step);
if (!isCurrent && !hasAutoStart) return null;
return (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
{isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>}
{hasAutoStart && (
<span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span>
)}
</span>
);
}
function buildStepEntry(
step: TaskMoveStep,
currentStepId: string | null | undefined,
onSelect: (stepId: string) => void,
): KanbanCardMenuEntry {
const isCurrent = step.id === currentStepId;
return {
kind: "item",
key: `step-${step.id}`,
testId: `task-context-step-${step.id}`,
disabled: isCurrent,
leading: <span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />,
label: <span className="flex-1 truncate">{step.title}</span>,
trailing: <StepBadges step={step} isCurrent={isCurrent} />,
onSelect: () => {
if (!isCurrent) onSelect(step.id);
},
};
}
function buildMoveToCurrentWorkflowSubmenu({
steps,
currentStepId,
disabled,
onMoveToStep,
}: {
steps: TaskMoveStep[];
currentStepId?: string | null;
disabled?: boolean;
onMoveToStep?: (stepId: string) => void;
}): KanbanCardMenuEntry | null {
if (!onMoveToStep || steps.length <= 1) return null;
return {
kind: "submenu",
key: "move-to",
testId: "task-context-move-to",
icon: <IconArrowRight className="mr-2 h-4 w-4" />,
label: "Move to",
disabled,
className: "w-48",
children: steps.map((step) => buildStepEntry(step, currentStepId, onMoveToStep)),
};
}
function buildWorkflowTargetEntry({
workflow,
steps,
disabled,
onSendToWorkflow,
}: {
workflow: TaskMoveWorkflow;
steps: TaskMoveStep[];
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}): KanbanCardMenuEntry {
if (steps.length === 0 || !onSendToWorkflow) {
return {
kind: "item",
key: `workflow-${workflow.id}`,
testId: `task-context-workflow-${workflow.id}`,
disabled: true,
label: <span className="flex-1 truncate">{workflow.name}</span>,
trailing: (
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
No steps
</span>
),
};
}
return {
kind: "submenu",
key: `workflow-${workflow.id}`,
testId: `task-context-workflow-${workflow.id}`,
label: <span className="truncate">{workflow.name}</span>,
disabled,
className: "w-48",
children: steps.map((step) =>
buildStepEntry(step, null, (stepId) => onSendToWorkflow(workflow.id, stepId)),
),
};
}
function buildSendToWorkflowSubmenu({
currentWorkflowId,
workflows,
stepsByWorkflowId,
disabled,
onSendToWorkflow,
}: {
currentWorkflowId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}): KanbanCardMenuEntry | null {
const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId);
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
return {
kind: "submenu",
key: "send-to-workflow",
testId: "task-context-send-to-workflow",
icon: <IconLogicBuffer className="mr-2 h-4 w-4" />,
label: "Send to workflow",
disabled,
className: "w-56",
children: targets.map((workflow) =>
buildWorkflowTargetEntry({
workflow,
steps: stepsByWorkflowId[workflow.id] ?? [],
disabled,
onSendToWorkflow,
}),
),
};
}
export function buildKanbanCardMenuEntries({
currentWorkflowId,
currentStepId,
workflows,
stepsByWorkflowId,
disabled,
isDeleting,
isArchiving,
onEdit,
onArchive,
onDelete,
onMoveToStep,
onSendToWorkflow,
}: BuildKanbanCardMenuEntriesArgs): KanbanCardMenuEntry[] {
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
const isProcessing = Boolean(disabled || isDeleting || isArchiving);
const entries: KanbanCardMenuEntry[] = [
{
kind: "item",
key: "edit",
icon: <IconPencil className="mr-2 h-4 w-4" />,
label: "Edit",
disabled: isProcessing || !onEdit,
onSelect: onEdit,
},
];
const moveToEntry = buildMoveToCurrentWorkflowSubmenu({
steps: currentSteps,
currentStepId,
disabled: isProcessing,
onMoveToStep,
});
if (moveToEntry) entries.push(moveToEntry);
const sendToEntry = buildSendToWorkflowSubmenu({
currentWorkflowId,
workflows: visibleWorkflows,
stepsByWorkflowId,
disabled: isProcessing,
onSendToWorkflow,
});
if (sendToEntry) entries.push(sendToEntry);
entries.push({
kind: "item",
key: "archive",
icon: isArchiving ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconArchive className="mr-2 h-4 w-4" />
),
label: "Archive",
disabled: isProcessing || !onArchive,
onSelect: onArchive,
});
entries.push({ kind: "separator", key: "delete-separator" });
entries.push({
kind: "item",
key: "delete",
icon: isDeleting ? (
<IconLoader className="mr-2 h-4 w-4 animate-spin" />
) : (
<IconTrash className="mr-2 h-4 w-4" />
),
label: "Delete",
destructive: true,
disabled: isProcessing || !onDelete,
onSelect: onDelete,
});
return entries;
}
export function useKanbanCardMoveTargets(
taskId: string,
steps?: WorkflowStep[],
): KanbanCardMoveTargets {
const workflows = useAppStore((state) => state.workflows.items);
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const currentWorkflowId = useMemo(() => {
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
if (snapshot.tasks.some((task) => task.id === taskId)) return workflowId;
}
return null;
}, [snapshots, taskId]);
const workflowItems = useMemo<TaskMoveWorkflow[]>(() => {
const current = workflows.find((workflow) => workflow.id === currentWorkflowId);
return workflows
.filter((workflow) => workflow.workspaceId === current?.workspaceId && !workflow.hidden)
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden }));
}, [workflows, currentWorkflowId]);
const stepsByWorkflowId = useMemo<Record<string, TaskMoveStep[]>>(() => {
const result: Record<string, TaskMoveStep[]> = {};
for (const [workflowId, snapshot] of Object.entries(snapshots)) {
result[workflowId] = snapshot.steps
.slice()
.sort((a, b) => a.position - b.position)
.map((step) => ({
id: step.id,
title: step.title,
color: step.color,
events: step.events,
}));
}
if (currentWorkflowId && steps) {
result[currentWorkflowId] = steps.map((step) => ({
id: step.id,
title: step.title,
color: step.color,
events: step.events,
}));
}
return result;
}, [snapshots, currentWorkflowId, steps]);
return { currentWorkflowId, workflowItems, stepsByWorkflowId };
}
function ContextEntry({ entry }: { entry: KanbanCardMenuEntry }) {
if (entry.kind === "separator") return <ContextMenuSeparator />;
if (entry.kind === "submenu") {
return (
<ContextMenuSub>
<ContextMenuSubTrigger data-testid={entry.testId} disabled={entry.disabled}>
{entry.icon}
{entry.label}
</ContextMenuSubTrigger>
<ContextMenuSubContent className={entry.className}>
{entry.children.map((child) => (
<ContextEntry key={child.key} entry={child} />
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
return (
<ContextMenuItem
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
onSelect={() => {
if (!entry.disabled) entry.onSelect?.();
}}
>
{entry.icon}
{entry.leading}
{entry.label}
{entry.trailing}
</ContextMenuItem>
);
}
function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) {
if (entry.kind === "separator") return <DropdownMenuSeparator />;
if (entry.kind === "submenu") {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
data-testid={entry.testId}
disabled={entry.disabled}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
{entry.icon}
{entry.label}
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent className={entry.className}>
{entry.children.map((child) => (
<DropdownEntry key={child.key} entry={child} />
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem
data-testid={entry.testId}
disabled={entry.disabled}
className={entry.destructive ? "text-destructive focus:text-destructive" : undefined}
onSelect={(event) => {
event.stopPropagation();
if (!entry.disabled) entry.onSelect?.();
}}
>
{entry.icon}
{entry.leading}
{entry.label}
{entry.trailing}
</DropdownMenuItem>
);
}
export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
return (
<>
{entries.map((entry) => (
<ContextEntry key={entry.key} entry={entry} />
))}
</>
);
}
export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) {
return (
<>
{entries.map((entry) => (
<DropdownEntry key={entry.key} entry={entry} />
))}
</>
);
}
@@ -0,0 +1,41 @@
"use client";
import { useMemo } from "react";
import { Card, CardContent } from "@kandev/ui/card";
import { KanbanCardBody } from "@/components/kanban-card-content";
import { resolveTaskRepositoryNames, type Task } from "@/components/kanban-card";
import { useAppStore } from "@/components/state-provider";
import type { Repository } from "@/lib/types/http";
function KanbanCardPreviewLayout({
task,
repositoryNames,
}: {
task: Task;
repositoryNames: string[];
}) {
return (
<Card
size="sm"
className="w-full py-0 cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border"
>
<CardContent className="px-2 py-1">
<KanbanCardBody task={task} repoNames={repositoryNames} />
</CardContent>
</Card>
);
}
export function KanbanCardPreview({ task }: { task: Task }) {
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const repositoryNames = useMemo(
() =>
resolveTaskRepositoryNames(
task,
Object.values(repositoriesByWorkspace).flat() as Repository[],
),
[repositoriesByWorkspace, task],
);
return <KanbanCardPreviewLayout task={task} repositoryNames={repositoryNames} />;
}
+146 -464
View File
@@ -1,40 +1,19 @@
"use client";
import { useMemo, useState } from "react";
import { useState } from "react";
import { useDraggable } from "@dnd-kit/core";
import { CSS } from "@dnd-kit/utilities";
import { KanbanCardContextMenu } from "@/components/kanban-card-context-menu";
import { KanbanCardShell } from "@/components/kanban-card-content";
import {
IconDots,
IconArrowsMaximize,
IconLoader,
IconAlertCircle,
IconSubtask,
} from "@tabler/icons-react";
import { Checkbox } from "@kandev/ui/checkbox";
import { Card, CardContent } from "@kandev/ui/card";
import { Badge } from "@kandev/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
DropdownMenuPortal,
DropdownMenuSeparator,
} from "@kandev/ui/dropdown-menu";
import type { TaskState, Repository } from "@/lib/types/http";
import { cn, getRepositoryDisplayName } from "@/lib/utils";
import { getTaskStateIcon, shouldUseQuestionTaskIcon } from "@/lib/ui/state-icons";
import { needsAction } from "@/lib/utils/needs-action";
buildKanbanCardMenuEntries,
useKanbanCardMoveTargets,
} from "@/components/kanban-card-menu-items";
import { useAppStore } from "@/components/state-provider";
import { PRTaskIcon } from "@/components/github/pr-task-icon";
import { RemoteCloudTooltip } from "@/components/task/remote-cloud-tooltip";
import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog";
import { useTaskPendingClarification } from "@/hooks/use-task-pending-clarification";
import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { getRepositoryDisplayName } from "@/lib/utils";
import type { Repository, TaskState } from "@/lib/types/http";
export interface Task {
id: string;
@@ -46,7 +25,6 @@ export interface Task {
repositoryId?: string;
/** All repositories linked to the task; used to render a "+N" chip for multi-repo. */
repositories?: Array<{ id: string; repository_id: string; position: number }>;
// Workflow fields
sessionCount?: number | null;
primarySessionId?: string | null;
reviewStatus?: "pending" | "approved" | "changes_requested" | "rejected" | null;
@@ -63,6 +41,12 @@ export interface WorkflowStep {
id: string;
title: string;
color: string;
events?: {
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_start?: Array<{ type: string; config?: Record<string, unknown> }>;
on_turn_complete?: Array<{ type: string; config?: Record<string, unknown> }>;
on_exit?: Array<{ type: string; config?: Record<string, unknown> }>;
};
}
interface KanbanCardProps {
@@ -80,383 +64,100 @@ interface KanbanCardProps {
isDeleting?: boolean;
isArchiving?: boolean;
isSelected?: boolean;
selectedIds?: Set<string>;
onToggleSelect?: (taskId: string) => void;
isMultiSelectMode?: boolean;
}
const REPO_CHIPS_VISIBLE = 2;
function RepoChipRow({ repoNames }: { repoNames: string[] }) {
if (repoNames.length === 0) return null;
const visible = repoNames.slice(0, REPO_CHIPS_VISIBLE);
const overflow = repoNames.slice(REPO_CHIPS_VISIBLE);
const row = (
<div className="mb-1 flex items-center gap-1 min-w-0 overflow-hidden">
{visible.map((name) => (
<span
key={name}
className="shrink-0 rounded-sm bg-muted/60 px-1 py-px text-[9px] font-medium text-muted-foreground leading-tight max-w-[8rem] truncate"
>
{name}
</span>
))}
{overflow.length > 0 && (
<span className="shrink-0 rounded-sm bg-muted px-1 py-px text-[9px] font-medium text-muted-foreground/80">
+{overflow.length}
</span>
)}
</div>
);
if (repoNames.length <= 1) return row;
return (
<Tooltip>
<TooltipTrigger asChild>{row}</TooltipTrigger>
<TooltipContent side="top" align="start">
<div className="flex flex-col gap-0.5 text-xs">
{repoNames.map((name) => (
<span key={name}>{name}</span>
))}
</div>
</TooltipContent>
</Tooltip>
);
}
function KanbanCardBody({
function useKanbanCardMenus({
task,
repoNames,
actions,
}: {
task: Task;
repoNames: string[];
actions?: React.ReactNode;
}) {
return (
<>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<RepoChipRow repoNames={repoNames} />
<div className="flex items-center gap-1 min-w-0">
<p
data-testid="task-card-title"
className="text-sm font-medium leading-tight line-clamp-1 min-w-0"
>
{task.title}
</p>
<PRTaskIcon taskId={task.id} />
</div>
</div>
{task.isRemoteExecutor && (
<RemoteCloudTooltip
taskId={task.id}
sessionId={task.primarySessionId ?? null}
fallbackName={task.primaryExecutorName ?? task.primaryExecutorType}
/>
)}
{actions}
</div>
{task.description && (
<p className="text-xs text-muted-foreground mt-1 leading-tight line-clamp-1">
{task.description}
</p>
)}
<KanbanCardBadges task={task} />
</>
);
}
function KanbanCardBadges({ task }: { task: Task }) {
const parentTitle = useAppStore((s) => {
if (!task.parentTaskId) return null;
return s.kanban.tasks.find((t) => t.id === task.parentTaskId)?.title ?? null;
});
const showRow =
(task.sessionCount && task.sessionCount > 1) ||
task.reviewStatus === "changes_requested" ||
task.reviewStatus === "pending" ||
task.parentTaskId;
if (!showRow) return null;
return (
<div className="flex flex-wrap items-center justify-end gap-2 mt-1 min-w-0">
{task.parentTaskId && (
<Badge variant="outline" className="text-xs h-5 gap-1 max-w-[160px] min-w-0">
<IconSubtask className="h-3 w-3 shrink-0" />
<span className="truncate">{parentTitle ?? "Subtask"}</span>
</Badge>
)}
{task.sessionCount && task.sessionCount > 1 && (
<Badge variant="secondary" className="text-xs h-5">
{task.sessionCount} sessions
</Badge>
)}
{task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && (
<div className="flex items-center gap-1 text-amber-700 dark:text-amber-600">
<IconAlertCircle className="h-3.5 w-3.5" />
<span className="text-[10px] font-medium">Approval Required</span>
</div>
)}
{task.reviewStatus === "changes_requested" && (
<Badge
variant="outline"
className="border-amber-500 text-amber-600 bg-amber-50 dark:bg-amber-950/50 text-xs h-5"
>
Changes Requested
</Badge>
)}
</div>
);
}
function KanbanCardLayout({
task,
repositoryNames,
className,
}: KanbanCardProps & { className?: string }) {
return (
<Card size="sm" className={cn("w-full py-0", className)}>
<CardContent className="px-2 py-1">
<KanbanCardBody task={task} repoNames={repositoryNames ?? []} />
</CardContent>
</Card>
);
}
function KanbanCardActions({
task,
showMaximizeButton,
onOpenFullPage,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
steps,
isDeleting,
isArchiving,
}: Pick<
KanbanCardProps,
| "task"
| "showMaximizeButton"
| "onOpenFullPage"
| "steps"
| "isDeleting"
| "isArchiving"
| "isSelected"
| "selectedIds"
| "onEdit"
| "onDelete"
| "onArchive"
| "onMove"
| "steps"
| "isDeleting"
| "isArchiving"
>) {
const [menuOpen, setMenuOpen] = useState(false);
const effectiveMenuOpen = menuOpen || Boolean(isDeleting) || Boolean(isArchiving);
const hasPendingClarificationRequest = useTaskPendingClarification(task.primarySessionId);
const showQuestionIcon = shouldUseQuestionTaskIcon(task.state, hasPendingClarificationRequest);
const statusIcon = getTaskStateIcon(task.state, "h-4 w-4", hasPendingClarificationRequest);
const hasKnownSession =
Boolean(task.primarySessionId) || Boolean(task.sessionCount && task.sessionCount > 0);
return (
<div className="flex items-center gap-2">
{(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) &&
statusIcon}
{showMaximizeButton && onOpenFullPage && hasKnownSession && (
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-accent rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(event) => {
event.stopPropagation();
onOpenFullPage(task);
}}
onPointerDown={(event) => event.stopPropagation()}
aria-label="Open full page"
title="Open full page"
>
<IconArrowsMaximize className="h-4 w-4" />
</button>
)}
<KanbanCardMenu
task={task}
effectiveMenuOpen={effectiveMenuOpen}
setMenuOpen={setMenuOpen}
isDeleting={isDeleting}
isArchiving={isArchiving}
onEdit={onEdit}
onDelete={onDelete}
onArchive={onArchive}
onMove={onMove}
steps={steps}
/>
</div>
);
}
function MoveToSubmenu({
task,
steps,
disabled,
onMove,
}: {
task: Task;
steps: WorkflowStep[];
disabled?: boolean;
onMove: (task: Task, targetStepId: string) => void;
}) {
return (
<DropdownMenuSub>
<DropdownMenuSubTrigger
disabled={disabled}
onClick={(event) => event.stopPropagation()}
onPointerDown={(event) => event.stopPropagation()}
>
Move to
</DropdownMenuSubTrigger>
<DropdownMenuPortal>
<DropdownMenuSubContent>
{steps
.filter((col) => col.id !== task.workflowStepId)
.map((col) => (
<DropdownMenuItem
key={col.id}
onClick={(event) => {
event.stopPropagation();
onMove(task, col.id);
}}
>
<div className={cn("w-2 h-2 rounded-full mr-2", col.color)} />
{col.title}
</DropdownMenuItem>
))}
</DropdownMenuSubContent>
</DropdownMenuPortal>
</DropdownMenuSub>
);
}
type KanbanCardMenuProps = {
task: Task;
effectiveMenuOpen: boolean;
setMenuOpen: (open: boolean) => void;
isDeleting?: boolean;
isArchiving?: boolean;
onEdit?: (task: Task) => void;
onDelete?: (task: Task) => void;
onArchive?: (task: Task) => void;
onMove?: (task: Task, targetStepId: string) => void;
steps?: WorkflowStep[];
};
function KanbanCardMenu(props: KanbanCardMenuProps) {
const { task, effectiveMenuOpen, setMenuOpen, isDeleting, isArchiving } = props;
const { onEdit, onDelete, onArchive, onMove, steps } = props;
const moveTargets = useKanbanCardMoveTargets(task.id, steps);
const moveTasks = useTaskWorkflowMove();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showArchiveConfirm, setShowArchiveConfirm] = useState(false);
const isProcessing = isDeleting || isArchiving;
const disabled = Boolean(isDeleting || isArchiving);
return (
<>
<DropdownMenu
open={effectiveMenuOpen}
onOpenChange={(open) => {
if (!open && isProcessing) return;
setMenuOpen(open);
}}
>
<DropdownMenuTrigger asChild>
<button
type="button"
className="text-muted-foreground hover:text-foreground hover:bg-muted rounded-sm p-1 -m-1 transition-colors cursor-pointer"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
aria-label="More options"
>
<IconDots className="h-4 w-4" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
disabled={isProcessing}
onClick={(e) => {
e.stopPropagation();
onEdit?.(task);
}}
>
Edit
</DropdownMenuItem>
{steps && steps.length > 1 && onMove && (
<MoveToSubmenu task={task} steps={steps} disabled={isProcessing} onMove={onMove} />
)}
{onArchive && (
<DropdownMenuItem
disabled={isProcessing}
onClick={(e) => {
e.stopPropagation();
setShowArchiveConfirm(true);
}}
>
{isArchiving ? <IconLoader className="mr-2 h-4 w-4 animate-spin" /> : null}
Archive
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isProcessing}
className="text-destructive focus:text-destructive"
onClick={(e) => {
e.stopPropagation();
setShowDeleteConfirm(true);
}}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
isDeleting={isDeleting}
onConfirm={() => onDelete?.(task)}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
isArchiving={isArchiving}
onConfirm={() => {
onArchive?.(task);
setShowArchiveConfirm(false);
}}
/>
</>
);
}
const runMoveTasks = (taskIds: string[], workflowId: string, stepId: string) => {
void moveTasks(taskIds, workflowId, stepId).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
};
function KanbanCardCheckbox({
taskId,
taskTitle,
isSelected,
onCheckboxClick,
}: {
taskId: string;
taskTitle: string;
isSelected?: boolean;
onCheckboxClick: (e: React.MouseEvent) => void;
}) {
return (
<div
className="mt-0.5 shrink-0"
onClick={onCheckboxClick}
onPointerDown={(e) => e.stopPropagation()}
data-testid={`task-select-checkbox-${taskId}`}
>
<Checkbox
checked={!!isSelected}
aria-label={`Select task ${taskTitle}`}
className="cursor-pointer border-muted-foreground/50"
/>
</div>
);
const moveToStepFromDropdown = (stepId: string) => {
if (onMove) {
onMove(task, stepId);
return;
}
if (moveTargets.currentWorkflowId) {
runMoveTasks([task.id], moveTargets.currentWorkflowId, stepId);
}
};
const selectedTaskIds = isSelected && selectedIds?.size ? [...selectedIds] : [task.id];
const moveSelectedToStep = (stepId: string) => {
if (selectedTaskIds.length === 1 && selectedTaskIds[0] === task.id && onMove) {
onMove(task, stepId);
return;
}
if (!moveTargets.currentWorkflowId) return;
runMoveTasks(selectedTaskIds, moveTargets.currentWorkflowId, stepId);
};
const menuBase = {
currentWorkflowId: moveTargets.currentWorkflowId,
currentStepId: task.workflowStepId,
workflows: moveTargets.workflowItems,
stepsByWorkflowId: moveTargets.stepsByWorkflowId,
disabled,
isDeleting,
isArchiving,
onEdit: onEdit ? () => onEdit(task) : undefined,
onArchive: onArchive ? () => setShowArchiveConfirm(true) : undefined,
onDelete: onDelete ? () => setShowDeleteConfirm(true) : undefined,
};
return {
dropdownMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveToStepFromDropdown,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks([task.id], workflowId, stepId);
},
}),
contextMenuEntries: buildKanbanCardMenuEntries({
...menuBase,
onMoveToStep: moveSelectedToStep,
onSendToWorkflow: (workflowId, stepId) => {
runMoveTasks(selectedTaskIds, workflowId, stepId);
},
}),
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
};
}
export function KanbanCard({
@@ -473,6 +174,7 @@ export function KanbanCard({
isDeleting,
isArchiving,
isSelected,
selectedIds,
onToggleSelect,
isMultiSelectMode,
}: KanbanCardProps) {
@@ -480,14 +182,26 @@ export function KanbanCard({
id: task.id,
disabled: isMultiSelectMode,
});
const isPreviewed = useAppStore((state) => state.kanbanPreviewedTaskId === task.id);
const style = {
transform: CSS.Translate.toString(transform),
transition: "none",
willChange: isDragging ? "transform" : undefined,
};
const {
dropdownMenuEntries,
contextMenuEntries,
showDeleteConfirm,
setShowDeleteConfirm,
showArchiveConfirm,
setShowArchiveConfirm,
} = useKanbanCardMenus({
task,
steps,
isDeleting,
isArchiving,
isSelected,
selectedIds,
onEdit,
onDelete,
onArchive,
onMove,
});
const handleClick = () => {
if (isMultiSelectMode) {
@@ -502,76 +216,44 @@ export function KanbanCard({
onToggleSelect?.(task.id);
};
const showCheckbox = isMultiSelectMode || !!isSelected;
return (
<Card
size="sm"
ref={setNodeRef}
style={style}
data-testid={`task-card-${task.id}`}
className={cn(
"group max-h-48 bg-card rounded-sm data-[size=sm]:py-1 cursor-pointer mb-2 w-full py-0 relative border border-border overflow-visible shadow-none ring-0",
needsAction(task) && !isSelected && "border-l-2 border-l-amber-500",
isDragging && "opacity-50 z-50",
isSelected && "ring-1 ring-primary/60 border-primary/60",
isPreviewed && !isSelected && "ring-2 ring-primary border-primary",
)}
onClick={handleClick}
{...(!isMultiSelectMode ? listeners : {})}
{...(!isMultiSelectMode ? attributes : {})}
>
<CardContent className="px-2 py-1">
<div className="flex items-start gap-1.5">
{showCheckbox && (
<KanbanCardCheckbox
taskId={task.id}
taskTitle={task.title}
isSelected={isSelected}
onCheckboxClick={handleCheckboxClick}
/>
)}
<div className="min-w-0 flex-1">
<KanbanCardBody
task={task}
repoNames={repositoryNames ?? []}
actions={
!isMultiSelectMode ? (
<KanbanCardActions
task={task}
showMaximizeButton={showMaximizeButton}
onOpenFullPage={onOpenFullPage}
onEdit={onEdit}
onDelete={onDelete}
onArchive={onArchive}
onMove={onMove}
steps={steps}
isDeleting={isDeleting}
isArchiving={isArchiving}
/>
) : undefined
}
/>
</div>
</div>
</CardContent>
</Card>
);
}
export function KanbanCardPreview({ task }: KanbanCardProps) {
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const repositoryNames = useMemo(
() => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()),
[repositoriesByWorkspace, task],
);
return (
<KanbanCardLayout
task={task}
repositoryNames={repositoryNames}
className="cursor-grabbing shadow-lg ring-0 pointer-events-none border border-border"
/>
<>
<KanbanCardContextMenu entries={contextMenuEntries}>
<KanbanCardShell
task={task}
repositoryNames={repositoryNames}
attributes={attributes}
listeners={listeners}
setNodeRef={setNodeRef}
transform={transform}
isDragging={isDragging}
isPreviewed={isPreviewed}
isSelected={isSelected}
isMultiSelectMode={isMultiSelectMode}
showMaximizeButton={showMaximizeButton}
isDeleting={isDeleting}
isArchiving={isArchiving}
menuEntries={dropdownMenuEntries}
onClick={handleClick}
onCheckboxClick={handleCheckboxClick}
onOpenFullPage={onOpenFullPage}
/>
</KanbanCardContextMenu>
<TaskDeleteConfirmDialog
open={showDeleteConfirm}
onOpenChange={setShowDeleteConfirm}
taskTitle={task.title}
isDeleting={isDeleting}
onConfirm={() => onDelete?.(task)}
/>
<TaskArchiveConfirmDialog
open={showArchiveConfirm}
onOpenChange={setShowArchiveConfirm}
taskTitle={task.title}
isArchiving={isArchiving}
onConfirm={() => onArchive?.(task)}
/>
</>
);
}
+1
View File
@@ -107,6 +107,7 @@ export function KanbanColumn({
isDeleting={deletingTaskId === task.id}
isArchiving={archivingTaskId === task.id}
isSelected={selectedIds?.has(task.id)}
selectedIds={selectedIds}
onToggleSelect={onToggleSelect}
isMultiSelectMode={isMultiSelectMode}
/>
@@ -12,7 +12,8 @@ import {
useSensors,
} from "@dnd-kit/core";
import { KanbanColumn } from "@/components/kanban-column";
import { KanbanCardPreview, type Task } from "@/components/kanban-card";
import { type Task } from "@/components/kanban-card";
import { KanbanCardPreview } from "@/components/kanban-card-preview";
import type { WorkflowStep } from "@/components/kanban-column";
import type { MoveTaskError } from "@/hooks/use-drag-and-drop";
import { useTaskActions } from "@/hooks/use-task-actions";
@@ -0,0 +1,230 @@
"use client";
import { IconArrowRight, IconLogicBuffer } from "@tabler/icons-react";
import {
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
} from "@kandev/ui/context-menu";
import { cn } from "@/lib/utils";
export type TaskMoveStep = {
id: string;
title: string;
color?: string | null;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
};
export type TaskMoveWorkflow = {
id: string;
name: string;
hidden?: boolean;
};
type TaskMoveContextMenuItemsProps = {
currentWorkflowId?: string | null;
currentStepId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
showSeparator?: boolean;
onMoveToStep?: (stepId: string) => void;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
};
export function stepHasAutoStart(step: TaskMoveStep) {
return step.events?.on_enter?.some((action) => action.type === "auto_start_agent") ?? false;
}
function StepMenuItem({
step,
currentStepId,
onSelect,
}: {
step: TaskMoveStep;
currentStepId?: string | null;
onSelect: (stepId: string) => void;
}) {
const isCurrent = step.id === currentStepId;
const hasAutoStart = stepHasAutoStart(step);
return (
<ContextMenuItem
key={step.id}
data-testid={`task-context-step-${step.id}`}
disabled={isCurrent}
onSelect={(event) => {
event.preventDefault();
if (!isCurrent) onSelect(step.id);
}}
>
<span className={cn("block h-2 w-2 rounded-full shrink-0", step.color ?? "")} />
<span className="flex-1 truncate">{step.title}</span>
{(isCurrent || hasAutoStart) && (
<span className="ml-auto flex items-center gap-1 text-[10px] text-muted-foreground">
{isCurrent && <span data-testid={`task-context-step-current-${step.id}`}>Current</span>}
{hasAutoStart && (
<span data-testid={`task-context-step-autostart-${step.id}`}>Auto-start</span>
)}
</span>
)}
</ContextMenuItem>
);
}
function MoveToCurrentWorkflowSubmenu({
steps,
currentStepId,
disabled,
onMoveToStep,
}: {
steps: TaskMoveStep[];
currentStepId?: string | null;
disabled?: boolean;
onMoveToStep?: (stepId: string) => void;
}) {
if (!onMoveToStep || steps.length <= 1) return null;
return (
<ContextMenuSub>
<ContextMenuSubTrigger data-testid="task-context-move-to" disabled={disabled}>
<IconArrowRight className="mr-2 h-4 w-4" />
Move to
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{steps.map((step) => (
<StepMenuItem
key={step.id}
step={step}
currentStepId={currentStepId}
onSelect={onMoveToStep}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function WorkflowTargetItem({
workflow,
steps,
disabled,
onSendToWorkflow,
}: {
workflow: TaskMoveWorkflow;
steps: TaskMoveStep[];
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}) {
if (steps.length === 0 || !onSendToWorkflow) {
return (
<ContextMenuItem
data-testid={`task-context-workflow-${workflow.id}`}
disabled
aria-disabled="true"
>
<span className="flex-1 truncate">{workflow.name}</span>
<span data-testid="task-context-disabled-reason" className="ml-2 text-[10px]">
No steps
</span>
</ContextMenuItem>
);
}
return (
<ContextMenuSub>
<ContextMenuSubTrigger
data-testid={`task-context-workflow-${workflow.id}`}
disabled={disabled}
>
<span className="truncate">{workflow.name}</span>
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-48">
{steps.map((step) => (
<StepMenuItem
key={step.id}
step={step}
onSelect={(stepId) => onSendToWorkflow(workflow.id, stepId)}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
function SendToWorkflowSubmenu({
currentWorkflowId,
workflows,
stepsByWorkflowId,
disabled,
onSendToWorkflow,
}: {
currentWorkflowId?: string | null;
workflows: TaskMoveWorkflow[];
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
disabled?: boolean;
onSendToWorkflow?: (workflowId: string, stepId: string) => void;
}) {
const targets = workflows.filter((workflow) => workflow.id !== currentWorkflowId);
if (!onSendToWorkflow || !currentWorkflowId || targets.length === 0) return null;
return (
<ContextMenuSub>
<ContextMenuSubTrigger data-testid="task-context-send-to-workflow" disabled={disabled}>
<IconLogicBuffer className="mr-2 h-4 w-4" />
Send to workflow
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-56">
{targets.map((workflow) => (
<WorkflowTargetItem
key={workflow.id}
workflow={workflow}
steps={stepsByWorkflowId[workflow.id] ?? []}
disabled={disabled}
onSendToWorkflow={onSendToWorkflow}
/>
))}
</ContextMenuSubContent>
</ContextMenuSub>
);
}
export function TaskMoveContextMenuItems({
currentWorkflowId,
currentStepId,
workflows,
stepsByWorkflowId,
disabled,
showSeparator = true,
onMoveToStep,
onSendToWorkflow,
}: TaskMoveContextMenuItemsProps) {
const visibleWorkflows = workflows.filter((workflow) => !workflow.hidden);
const currentSteps = currentWorkflowId ? (stepsByWorkflowId[currentWorkflowId] ?? []) : [];
const hasSameWorkflowMove = Boolean(onMoveToStep && currentSteps.length > 1);
const hasCrossWorkflowMove = Boolean(
onSendToWorkflow &&
currentWorkflowId &&
visibleWorkflows.some((workflow) => workflow.id !== currentWorkflowId),
);
if (!hasSameWorkflowMove && !hasCrossWorkflowMove) return null;
return (
<>
{showSeparator && <ContextMenuSeparator />}
<MoveToCurrentWorkflowSubmenu
steps={currentSteps}
currentStepId={currentStepId}
disabled={disabled}
onMoveToStep={onMoveToStep}
/>
<SendToWorkflowSubmenu
currentWorkflowId={currentWorkflowId}
workflows={visibleWorkflows}
stepsByWorkflowId={stepsByWorkflowId}
disabled={disabled}
onSendToWorkflow={onSendToWorkflow}
/>
</>
);
}
@@ -180,7 +180,13 @@ type TaskSessionSidebarProps = {
workflowId: string | null;
};
type StepInfo = { id: string; title: string; color: string; position: number };
type StepInfo = {
id: string;
title: string;
color: string;
position: number;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
};
type SidebarItem = Omit<ReturnType<typeof toSidebarItem>, "workflowId"> & { workflowId?: string };
function buildArchivedItem(s: ReturnType<typeof useArchivedTaskState>): SidebarItem {
@@ -221,6 +227,7 @@ function useSidebarData(workspaceId: string | null) {
const gitStatusByEnvId = useAppStore((state) => state.gitStatus.byEnvironmentId);
const envIdBySessionId = useAppStore((state) => state.environmentIdBySessionId);
const snapshots = useAppStore((state) => state.kanbanMulti.snapshots);
const workflows = useAppStore((state) => state.workflows.items);
const isMultiLoading = useAppStore((state) => state.kanbanMulti.isLoading);
const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId);
const taskPRsByTaskId = useAppStore((state) => state.taskPRs.byTaskId);
@@ -308,6 +315,9 @@ function useSidebarData(workspaceId: string | null) {
isLoadingWorkflow,
tasksWithRepositories,
primarySessionIds,
workflows: workflows
.filter((workflow) => !workspaceId || workflow.workspaceId === workspaceId)
.map((workflow) => ({ id: workflow.id, name: workflow.name, hidden: workflow.hidden })),
};
}
@@ -521,6 +531,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
activeTaskId,
selectedTaskId,
stepsByWorkflowId,
workflows,
isLoadingWorkflow,
tasksWithRepositories,
primarySessionIds,
@@ -569,6 +580,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({
<PanelBody className="space-y-4 p-0">
<TaskSwitcher
grouped={grouped}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
+49 -55
View File
@@ -3,7 +3,6 @@
import { cloneElement, isValidElement, memo, useState } from "react";
import {
IconChevronDown,
IconArrowRight,
IconPencil,
IconCopy,
IconArchive,
@@ -15,15 +14,17 @@ import {
ContextMenuContent,
ContextMenuItem,
ContextMenuSeparator,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@kandev/ui/context-menu";
import type { TaskState, TaskSessionState } from "@/lib/types/http";
import { cn } from "@/lib/utils";
import { TaskItem } from "./task-item";
import type { GroupedSidebarList, SidebarGroup } from "@/lib/sidebar/apply-view";
import {
TaskMoveContextMenuItems,
type TaskMoveWorkflow,
} from "@/components/task/task-move-context-menu";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
export type TaskSwitcherItem = {
id: string;
@@ -54,10 +55,16 @@ export type TaskSwitcherItem = {
issueInfo?: { url: string; number: number };
};
type StepDef = { id: string; title: string; color?: string };
type StepDef = {
id: string;
title: string;
color?: string;
events?: { on_enter?: Array<{ type: string; config?: Record<string, unknown> }> };
};
type TaskSwitcherProps = {
grouped: GroupedSidebarList;
workflows?: TaskMoveWorkflow[];
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
@@ -132,6 +139,7 @@ type TaskRowProps = {
task: TaskSwitcherItem;
isSubTask?: boolean;
subtaskToggle?: SubtaskToggleInfo;
workflows?: TaskMoveWorkflow[];
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
@@ -147,6 +155,7 @@ function TaskRow({
task,
isSubTask,
subtaskToggle,
workflows,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
@@ -162,6 +171,8 @@ function TaskRow({
return (
<TaskItemWithContextMenu
task={task}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
steps={taskSteps}
onRenameTask={onRenameTask}
onArchiveTask={onArchiveTask}
@@ -200,6 +211,7 @@ function TaskRow({
function GroupSection({
group,
subTasksByParentId,
workflows,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
@@ -217,6 +229,7 @@ function GroupSection({
}: {
group: SidebarGroup;
subTasksByParentId: Map<string, TaskSwitcherItem[]>;
workflows?: TaskMoveWorkflow[];
stepsByWorkflowId?: Record<string, StepDef[]>;
activeTaskId: string | null;
selectedTaskId: string | null;
@@ -238,6 +251,7 @@ function GroupSection({
);
const collapsedSubs = new Set(collapsedSubtaskParentIds ?? []);
const rowProps = {
workflows,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
@@ -287,6 +301,8 @@ function GroupSection({
function TaskItemWithContextMenu({
task,
workflows,
stepsByWorkflowId,
steps,
children,
onRenameTask,
@@ -296,6 +312,8 @@ function TaskItemWithContextMenu({
isDeleting,
}: {
task: TaskSwitcherItem;
workflows?: TaskMoveWorkflow[];
stepsByWorkflowId?: Record<string, StepDef[]>;
steps?: StepDef[];
children: React.ReactElement<{ menuOpen?: boolean }>;
onRenameTask?: (taskId: string, currentTitle: string) => void;
@@ -306,7 +324,12 @@ function TaskItemWithContextMenu({
}) {
const [contextOpen, setContextOpen] = useState(false);
const [menuKey, setMenuKey] = useState(0);
const moveTasks = useTaskWorkflowMove();
const menuOpen = contextOpen || isDeleting === true;
const closeMenu = () => {
setContextOpen(false);
setMenuKey((k) => k + 1);
};
return (
<ContextMenu key={menuKey} onOpenChange={setContextOpen}>
@@ -330,15 +353,26 @@ function TaskItemWithContextMenu({
Archive
</ContextMenuItem>
)}
{onMoveToStep && task.workflowId && steps && steps.length > 0 && (
<MoveToStepSubmenu
steps={steps}
{task.workflowId && (
<TaskMoveContextMenuItems
currentWorkflowId={task.workflowId}
currentStepId={task.workflowStepId}
disabled={isDeleting}
onSelect={(stepId) => {
setContextOpen(false);
setMenuKey((k) => k + 1);
onMoveToStep(task.id, task.workflowId!, stepId);
workflows={workflows ?? []}
stepsByWorkflowId={stepsByWorkflowId ?? (steps ? { [task.workflowId]: steps } : {})}
disabled={isDeleting || task.isArchived}
onMoveToStep={
onMoveToStep
? (stepId) => {
closeMenu();
onMoveToStep(task.id, task.workflowId!, stepId);
}
: undefined
}
onSendToWorkflow={(workflowId, stepId) => {
closeMenu();
void moveTasks([task.id], workflowId, stepId).catch(() => {
// useTaskWorkflowMove already shows the failure toast.
});
}}
/>
)}
@@ -364,48 +398,6 @@ function TaskItemWithContextMenu({
);
}
function MoveToStepSubmenu({
steps,
currentStepId,
disabled,
onSelect,
}: {
steps: StepDef[];
currentStepId?: string;
disabled?: boolean;
onSelect: (stepId: string) => void;
}) {
return (
<>
<ContextMenuSeparator />
<ContextMenuSub>
<ContextMenuSubTrigger disabled={disabled}>
<IconArrowRight className="mr-2 h-4 w-4" />
Move to
</ContextMenuSubTrigger>
<ContextMenuSubContent className="w-44">
{steps.map((step) => (
<ContextMenuItem
key={step.id}
disabled={step.id === currentStepId}
onSelect={(e) => {
e.preventDefault();
onSelect(step.id);
}}
>
<span className={cn("block h-2 w-2 rounded-full shrink-0", step.color)} />
<span className="flex-1 truncate">{step.title}</span>
{step.id === currentStepId && (
<span className="ml-auto text-[10px] text-muted-foreground">Current</span>
)}
</ContextMenuItem>
))}
</ContextMenuSubContent>
</ContextMenuSub>
</>
);
}
function cloneWithMenuOpen(
children: React.ReactElement<{ menuOpen?: boolean }>,
menuOpen: boolean,
@@ -416,6 +408,7 @@ function cloneWithMenuOpen(
export const TaskSwitcher = memo(function TaskSwitcher({
grouped,
workflows,
stepsByWorkflowId,
activeTaskId,
selectedTaskId,
@@ -450,6 +443,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({
key={group.key}
group={group}
subTasksByParentId={grouped.subTasksByParentId}
workflows={workflows}
stepsByWorkflowId={stepsByWorkflowId}
activeTaskId={activeTaskId}
selectedTaskId={selectedTaskId}
+52
View File
@@ -59,6 +59,58 @@ export class KanbanPage {
});
}
contextMoveTo(): Locator {
return this.page.getByTestId("task-context-move-to");
}
contextSendToWorkflow(): Locator {
return this.page.getByTestId("task-context-send-to-workflow");
}
contextWorkflow(workflowId: string): Locator {
return this.page.getByTestId(`task-context-workflow-${workflowId}`);
}
contextStep(stepId: string): Locator {
return this.page.getByTestId(`task-context-step-${stepId}`);
}
contextAutoStartStep(stepId: string): Locator {
return this.page.getByTestId(`task-context-step-autostart-${stepId}`);
}
async openTaskContextMenu(taskId: string) {
const card = this.taskCard(taskId);
await card.waitFor({ state: "visible" });
await card.click({ button: "right" });
}
async openTaskActionsMenu(taskId: string) {
const card = this.taskCard(taskId);
await card.waitFor({ state: "visible" });
await card.getByLabel("More options").click();
}
async moveTaskWithinWorkflow(taskId: string, stepId: string) {
await this.openTaskContextMenu(taskId);
await this.contextMoveTo().hover();
await this.contextStep(stepId).click();
}
async sendTaskToWorkflow(taskId: string, workflowId: string, stepId: string) {
await this.openTaskContextMenu(taskId);
await this.contextSendToWorkflow().hover();
await this.contextWorkflow(workflowId).hover();
await this.contextStep(stepId).click();
}
async sendTaskToWorkflowFromActions(taskId: string, workflowId: string, stepId: string) {
await this.openTaskActionsMenu(taskId);
await this.contextSendToWorkflow().hover();
await this.contextWorkflow(workflowId).hover();
await this.contextStep(stepId).click();
}
async enableMultiSelect() {
await this.multiSelectToggle.first().waitFor({ state: "visible" });
const isEnabled = await this.page
+23
View File
@@ -150,6 +150,29 @@ export class SessionPage {
return this.sidebar.getByText(title, { exact: false });
}
sidebarTaskItem(title: string): Locator {
return this.sidebar.getByTestId("sidebar-task-item").filter({
has: this.page.getByText(title, { exact: false }),
});
}
async openSidebarTaskContextMenu(title: string): Promise<void> {
const taskRow = this.sidebarTaskItem(title).first();
await taskRow.waitFor({ state: "visible" });
await taskRow.click({ button: "right" });
}
async sendSidebarTaskToWorkflow(
title: string,
workflowId: string,
stepId: string,
): Promise<void> {
await this.openSidebarTaskContextMenu(title);
await this.page.getByTestId("task-context-send-to-workflow").hover();
await this.page.getByTestId(`task-context-workflow-${workflowId}`).hover();
await this.page.getByTestId(`task-context-step-${stepId}`).click();
}
/**
* Sidebar state indicator — returns the first icon matching the given state label.
* Accepts "Turn Finished" (review/completed), "Running" (in-progress), or "Backlog".
@@ -0,0 +1,101 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
test.describe("Cross-workflow selected batch moves", () => {
test("right-clicking a selected task sends the selected batch", async ({
testPage,
apiClient,
seedData,
}) => {
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Batch Target Workflow",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Batch Incoming", 0);
const [first, second, unselected] = await Promise.all([
apiClient.createTask(seedData.workspaceId, "Batch Move First", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
apiClient.createTask(seedData.workspaceId, "Batch Move Second", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
apiClient.createTask(seedData.workspaceId, "Batch Move Unselected", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.selectTask(first.id);
await kanban.selectTask(second.id);
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
await kanban.sendTaskToWorkflow(first.id, targetWorkflow.id, targetStep.id);
await expect(kanban.taskCard(first.id)).not.toBeVisible({ timeout: 10_000 });
await expect(kanban.taskCard(second.id)).not.toBeVisible();
await expect(kanban.taskCard(unselected.id)).toBeVisible();
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
await expect(kanban.board).toBeVisible();
await expect(kanban.taskCardInColumn("Batch Move First", targetStep.id)).toBeVisible({
timeout: 10_000,
});
await expect(kanban.taskCardInColumn("Batch Move Second", targetStep.id)).toBeVisible();
await expect(kanban.taskCardInColumn("Batch Move Unselected", targetStep.id)).not.toBeVisible();
});
test("right-clicking an unselected task moves only that task", async ({
testPage,
apiClient,
seedData,
}) => {
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Clicked Only Target Workflow",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Clicked Incoming", 0);
const [selectedA, selectedB, clicked] = await Promise.all([
apiClient.createTask(seedData.workspaceId, "Clicked Only Selected A", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
apiClient.createTask(seedData.workspaceId, "Clicked Only Selected B", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
apiClient.createTask(seedData.workspaceId, "Clicked Only Moved", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
}),
]);
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.selectTask(selectedA.id);
await kanban.selectTask(selectedB.id);
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
await kanban.sendTaskToWorkflow(clicked.id, targetWorkflow.id, targetStep.id);
await expect(kanban.taskCard(clicked.id)).not.toBeVisible({ timeout: 10_000 });
await expect(kanban.taskCard(selectedA.id)).toBeVisible();
await expect(kanban.taskCard(selectedB.id)).toBeVisible();
await expect(kanban.multiSelectToolbar).toContainText("2 selected");
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
await expect(kanban.board).toBeVisible();
await expect(kanban.taskCardInColumn("Clicked Only Moved", targetStep.id)).toBeVisible({
timeout: 10_000,
});
await expect(
kanban.taskCardInColumn("Clicked Only Selected A", targetStep.id),
).not.toBeVisible();
await expect(
kanban.taskCardInColumn("Clicked Only Selected B", targetStep.id),
).not.toBeVisible();
});
});
@@ -0,0 +1,167 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
test.describe("Cross-workflow task move from home", () => {
test("keeps same-workflow Move to as the short path", async ({
testPage,
apiClient,
seedData,
}) => {
const targetStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
if (!targetStep) {
test.skip(true, "seed workflow needs at least two steps");
return;
}
const task = await apiClient.createTask(seedData.workspaceId, "Same Workflow Context Move", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.openTaskContextMenu(task.id);
await expect(kanban.contextMoveTo()).toBeVisible();
await expect(kanban.contextSendToWorkflow()).not.toBeVisible();
await kanban.contextMoveTo().hover();
await kanban.contextStep(targetStep.id).click();
await expect(kanban.taskCardInColumn("Same Workflow Context Move", targetStep.id)).toBeVisible({
timeout: 10_000,
});
});
test("sends one task to another workflow without changing the current view", async ({
testPage,
apiClient,
seedData,
}) => {
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Target Workflow Context Send",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Context Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const beforeUrl = testPage.url();
await kanban.sendTaskToWorkflow(task.id, targetWorkflow.id, targetStep.id);
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
expect(testPage.url()).toBe(beforeUrl);
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
await expect(kanban.board).toBeVisible();
await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible(
{ timeout: 10_000 },
);
await testPage.reload();
await expect(kanban.taskCardInColumn("Cross Workflow Context Task", targetStep.id)).toBeVisible(
{ timeout: 10_000 },
);
});
test("keeps right-click and three-dot menus aligned for workflow moves", async ({
testPage,
apiClient,
seedData,
}) => {
const sameWorkflowStep = seedData.steps.find((step) => step.id !== seedData.startStepId);
if (!sameWorkflowStep) {
test.skip(true, "seed workflow needs at least two steps");
return;
}
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Target Workflow Actions Send",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Incoming", 0);
const task = await apiClient.createTask(seedData.workspaceId, "Cross Workflow Actions Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
const expectedLabels = ["Edit", "Move to", "Send to workflow", "Archive", "Delete"];
await kanban.openTaskContextMenu(task.id);
for (const label of expectedLabels) {
await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible();
}
await testPage.keyboard.press("Escape");
await kanban.openTaskActionsMenu(task.id);
for (const label of expectedLabels) {
await expect(testPage.getByRole("menuitem", { name: label })).toBeVisible();
}
await kanban.contextSendToWorkflow().hover();
await kanban.contextWorkflow(targetWorkflow.id).hover();
await kanban.contextStep(targetStep.id).click();
await expect(kanban.taskCard(task.id)).not.toBeVisible({ timeout: 10_000 });
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
await expect(kanban.taskCardInColumn("Cross Workflow Actions Task", targetStep.id)).toBeVisible(
{ timeout: 10_000 },
);
});
test("shows no-step workflows as disabled send targets", async ({
testPage,
apiClient,
seedData,
}) => {
const emptyWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Empty Workflow Context Send",
);
const task = await apiClient.createTask(seedData.workspaceId, "No Step Target Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.openTaskContextMenu(task.id);
await kanban.contextSendToWorkflow().hover();
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeVisible();
await expect(kanban.contextWorkflow(emptyWorkflow.id)).toBeDisabled();
await expect(
kanban.contextWorkflow(emptyWorkflow.id).getByTestId("task-context-disabled-reason"),
).toContainText("No steps");
});
test("marks auto-start target steps before moving", async ({ testPage, apiClient, seedData }) => {
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Auto Start Context Target",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Run agent", 0);
await apiClient.updateWorkflowStep(targetStep.id, {
prompt: 'e2e:message("context move auto-started")',
events: { on_enter: [{ type: "auto_start_agent" }] },
});
const task = await apiClient.createTask(seedData.workspaceId, "Auto Start Marker Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
agent_profile_id: seedData.agentProfileId,
repository_ids: [seedData.repositoryId],
});
const kanban = new KanbanPage(testPage);
await kanban.goto();
await kanban.openTaskContextMenu(task.id);
await kanban.contextSendToWorkflow().hover();
await kanban.contextWorkflow(targetWorkflow.id).hover();
await expect(kanban.contextAutoStartStep(targetStep.id)).toBeVisible();
});
});
@@ -0,0 +1,56 @@
import { test, expect } from "../../fixtures/test-base";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
test.describe("Task sidebar send to workflow", () => {
test("right-click sends a sidebar task to another workflow without navigation", async ({
testPage,
apiClient,
seedData,
}) => {
const targetWorkflow = await apiClient.createWorkflow(
seedData.workspaceId,
"Sidebar Target Workflow",
);
const targetStep = await apiClient.createWorkflowStep(targetWorkflow.id, "Sidebar Incoming", 0);
const anchor = await apiClient.createTask(seedData.workspaceId, "Sidebar Anchor Task", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await apiClient.createTask(seedData.workspaceId, "Sidebar Send Candidate", {
workflow_id: seedData.workflowId,
workflow_step_id: seedData.startStepId,
repository_ids: [seedData.repositoryId],
});
await testPage.goto(`/t/${anchor.id}`);
const session = new SessionPage(testPage);
await session.waitForLoad();
await expect(session.sidebarTaskItem("Sidebar Send Candidate")).toBeVisible({
timeout: 15_000,
});
const beforeUrl = testPage.url();
await session.sendSidebarTaskToWorkflow(
"Sidebar Send Candidate",
targetWorkflow.id,
targetStep.id,
);
await expect(testPage.getByText(/Moved task to/i)).toBeVisible({ timeout: 10_000 });
expect(testPage.url()).toBe(beforeUrl);
const kanban = new KanbanPage(testPage);
await testPage.goto(`/?workflowId=${targetWorkflow.id}`);
await expect(kanban.board).toBeVisible();
await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({
timeout: 10_000,
});
await testPage.reload();
await expect(kanban.taskCardInColumn("Sidebar Send Candidate", targetStep.id)).toBeVisible({
timeout: 10_000,
});
});
});
@@ -0,0 +1,98 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move";
import { bulkMoveSelectedTasks } from "@/lib/api";
const mockToast = vi.fn();
vi.mock("@/components/toast-provider", () => ({
useToast: () => ({ toast: mockToast }),
}));
vi.mock("@/lib/api", () => ({
bulkMoveSelectedTasks: vi.fn(),
}));
const mockBulkMoveSelectedTasks = vi.mocked(bulkMoveSelectedTasks);
const TASK_1 = "task-1";
const TASK_2 = "task-2";
const TARGET_WORKFLOW_ID = "wf-2";
const TARGET_STEP_ID = "step-2";
describe("useTaskWorkflowMove", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("dedupes and filters task ids before moving", async () => {
mockBulkMoveSelectedTasks.mockResolvedValue({ moved_count: 2 });
const { result } = renderHook(() => useTaskWorkflowMove());
await act(async () => {
await result.current([TASK_1, "", TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
});
expect(mockBulkMoveSelectedTasks).toHaveBeenCalledWith({
task_ids: [TASK_1, TASK_2],
target_workflow_id: TARGET_WORKFLOW_ID,
target_step_id: TARGET_STEP_ID,
});
});
it("does nothing for an empty task id list", async () => {
const { result } = renderHook(() => useTaskWorkflowMove());
await act(async () => {
await result.current([""], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
});
expect(mockBulkMoveSelectedTasks).not.toHaveBeenCalled();
expect(mockToast).not.toHaveBeenCalled();
});
it("shows singular, plural, and zero-count success messages", async () => {
mockBulkMoveSelectedTasks
.mockResolvedValueOnce({ moved_count: 1 })
.mockResolvedValueOnce({ moved_count: 2 })
.mockResolvedValueOnce({ moved_count: 0 });
const { result } = renderHook(() => useTaskWorkflowMove());
await act(async () => {
await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
await result.current([TASK_1, TASK_2], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
await result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID);
});
expect(mockToast).toHaveBeenNthCalledWith(1, {
title: "Moved task to workflow",
description: "Switch to the destination workflow to see it.",
variant: "success",
});
expect(mockToast).toHaveBeenNthCalledWith(2, {
title: "Moved 2 tasks to workflow",
description: "Switch to the destination workflow to see them.",
variant: "success",
});
expect(mockToast).toHaveBeenNthCalledWith(3, {
title: "Moved 0 tasks to workflow",
description: "Switch to the destination workflow to see them.",
variant: "success",
});
});
it("shows an error toast and rethrows move failures", async () => {
const error = new Error("cannot move running task");
mockBulkMoveSelectedTasks.mockRejectedValue(error);
const { result } = renderHook(() => useTaskWorkflowMove());
await expect(result.current([TASK_1], TARGET_WORKFLOW_ID, TARGET_STEP_ID)).rejects.toThrow(
error,
);
expect(mockToast).toHaveBeenCalledWith({
title: "Failed to move task",
description: "cannot move running task",
variant: "error",
});
});
});
+51
View File
@@ -0,0 +1,51 @@
"use client";
import { useCallback } from "react";
import { bulkMoveSelectedTasks } from "@/lib/api";
import { useToast } from "@/components/toast-provider";
function errorMessage(error: unknown) {
return error instanceof Error ? error.message : "Failed to move task";
}
function movedTitle(movedCount: number) {
if (movedCount === 1) return "Moved task to workflow";
return `Moved ${movedCount} tasks to workflow`;
}
function movedDescription(movedCount: number) {
return movedCount === 1
? "Switch to the destination workflow to see it."
: "Switch to the destination workflow to see them.";
}
export function useTaskWorkflowMove() {
const { toast } = useToast();
return useCallback(
async (taskIds: string[], targetWorkflowId: string, targetStepId: string) => {
const ids = [...new Set(taskIds.filter(Boolean))];
if (ids.length === 0) return;
try {
const result = await bulkMoveSelectedTasks({
task_ids: ids,
target_workflow_id: targetWorkflowId,
target_step_id: targetStepId,
});
toast({
title: movedTitle(result.moved_count),
description: movedDescription(result.moved_count),
variant: "success",
});
} catch (error) {
toast({
title: "Failed to move task",
description: errorMessage(error),
variant: "error",
});
throw error;
}
},
[toast],
);
}
+10
View File
@@ -120,6 +120,16 @@ export async function moveTask(
});
}
export async function bulkMoveSelectedTasks(
payload: { task_ids: string[]; target_workflow_id: string; target_step_id: string },
options?: ApiRequestOptions,
) {
return fetchJson<{ moved_count: number }>("/api/v1/tasks/bulk-move", {
...options,
init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) },
});
}
export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
}
+1
View File
@@ -117,6 +117,7 @@ export type KanbanUpdatePayload = {
export type TaskEventPayload = {
task_id: string;
workflow_id: string;
old_workflow_id?: string | null;
workflow_step_id: string;
title: string;
description?: string;
+32
View File
@@ -195,6 +195,38 @@ describe("task.updated primary-session focus follow", () => {
});
});
describe("task.updated cross-workflow placement", () => {
it("removes the task from its old workflow snapshot before upserting into the new one", () => {
const task = { id: "t1", title: "Test", workflowId: "wf1", workflowStepId: "step1" };
const store = makeStore({
kanban: {
workflowId: "wf1",
steps: [],
tasks: [task],
} as unknown as AppState["kanban"],
kanbanMulti: {
isLoading: false,
snapshots: {
wf1: { workflow: { id: "wf1" }, steps: [], tasks: [task] },
wf2: { workflow: { id: "wf2" }, steps: [], tasks: [] },
},
} as unknown as AppState["kanbanMulti"],
});
const handlers = registerTasksHandlers(store);
handlers["task.updated"]!(
makeMessage({ ...makeTask("t1", null, "wf2"), old_workflow_id: "wf1" }),
);
const state = store.getState();
expect(state.kanban.tasks).toHaveLength(0);
expect(state.kanbanMulti.snapshots.wf1.tasks).toHaveLength(0);
expect(state.kanbanMulti.snapshots.wf2.tasks).toHaveLength(1);
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.id).toBe("t1");
expect(state.kanbanMulti.snapshots.wf2.tasks[0]?.workflowStepId).toBe("step1");
});
});
describe("task.deleted cleanup", () => {
it("removes the deleted task from recent task history", () => {
const store = makeStore({
+10 -3
View File
@@ -36,6 +36,7 @@ function upsertMultiTask(state: AppState, workflowId: string, task: KanbanTask):
type TaskEventPayload = TaskLike & {
workflow_id: string;
old_workflow_id?: string | null;
is_ephemeral?: boolean;
archived_at?: string | null;
};
@@ -123,12 +124,18 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
store.setState((state) => {
const wfId = message.payload.workflow_id;
const oldWfId = message.payload.old_workflow_id;
let next = state;
if (message.payload.archived_at) {
return removeTaskFromBothKanbans(state, wfId, taskId);
if (oldWfId && oldWfId !== wfId) {
next = removeTaskFromBothKanbans(next, oldWfId, taskId);
}
return upsertTaskInBothKanbans(state, wfId, message.payload);
if (message.payload.archived_at) {
return removeTaskFromBothKanbans(next, wfId, taskId);
}
return upsertTaskInBothKanbans(next, wfId, message.payload);
});
// Follow focus to the new primary when: