diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow.go b/apps/backend/internal/orchestrator/event_handlers_workflow.go index 0e86592bbc..be663c0061 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow.go @@ -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 diff --git a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go index 252aabcb7e..497cd8879b 100644 --- a/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_workflow_moved_test.go @@ -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) } }) }) diff --git a/apps/backend/internal/task/handlers/errors.go b/apps/backend/internal/task/handlers/errors.go index 701368427c..ff3f2d5a31 100644 --- a/apps/backend/internal/task/handlers/errors.go +++ b/apps/backend/internal/task/handlers/errors.go @@ -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 diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index 94d01ca72a..3e6be083a5 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -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 } diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index c5d80fcdf6..70f3becdfb 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -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 diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 768741a0b0..969d8d795a 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -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, diff --git a/apps/backend/internal/task/service/service_workflow.go b/apps/backend/internal/task/service/service_workflow.go index 8ac42517b6..cc2ee0ff53 100644 --- a/apps/backend/internal/task/service/service_workflow.go +++ b/apps/backend/internal/task/service/service_workflow.go @@ -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", diff --git a/apps/backend/internal/task/service/service_workflow_test.go b/apps/backend/internal/task/service/service_workflow_test.go new file mode 100644 index 0000000000..87b571bf81 --- /dev/null +++ b/apps/backend/internal/task/service/service_workflow_test.go @@ -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) + } +} diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 3b6444a716..04b8209c25 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -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"; diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index 8945e31a8f..2e658d1dd7 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -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) { const [moveError, setMoveError] = useState(null); diff --git a/apps/web/components/kanban-card-content.tsx b/apps/web/components/kanban-card-content.tsx new file mode 100644 index 0000000000..f58ae17c24 --- /dev/null +++ b/apps/web/components/kanban-card-content.tsx @@ -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 = ( +
+ {visible.map((name) => ( + + {name} + + ))} + {overflow.length > 0 && ( + + +{overflow.length} + + )} +
+ ); + if (repoNames.length <= 1) return row; + return ( + + {row} + +
+ {repoNames.map((name) => ( + {name} + ))} +
+
+
+ ); +} + +export function KanbanCardBody({ + task, + repoNames, + actions, +}: { + task: Task; + repoNames: string[]; + actions?: React.ReactNode; +}) { + return ( + <> +
+
+ +
+

+ {task.title} +

+ +
+
+ {task.isRemoteExecutor && ( + + )} + {actions} +
+ {task.description && ( +

+ {task.description} +

+ )} + + + ); +} + +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 ( +
+ {task.parentTaskId && ( + + + {parentTitle ?? "Subtask"} + + )} + {task.sessionCount && task.sessionCount > 1 && ( + + {task.sessionCount} sessions + + )} + {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( +
+ + Approval Required +
+ )} + {task.reviewStatus === "changes_requested" && ( + + Changes Requested + + )} +
+ ); +} + +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 ( +
+ {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && + statusIcon} + {showMaximizeButton && onOpenFullPage && hasKnownSession && ( + + )} + +
+ ); +} + +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 ( + { + if (!open && isProcessing) return; + setMenuOpen(open); + }} + > + + + + + + + + ); +} + +function KanbanCardCheckbox({ + taskId, + taskTitle, + isSelected, + onCheckboxClick, +}: { + taskId: string; + taskTitle: string; + isSelected?: boolean; + onCheckboxClick: (e: React.MouseEvent) => void; +}) { + return ( +
e.stopPropagation()} + data-testid={`task-select-checkbox-${taskId}`} + > + +
+ ); +} + +function KanbanCardActionSlot({ + isMultiSelectMode, + task, + showMaximizeButton, + onOpenFullPage, + menuEntries, + isDeleting, + isArchiving, +}: KanbanCardActionProps & { isMultiSelectMode?: boolean }) { + if (isMultiSelectMode) return null; + return ( + + ); +} + +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 ( + + +
+ {showCheckbox && ( + + )} +
+ + } + /> +
+
+
+
+ ); +} diff --git a/apps/web/components/kanban-card-context-menu.tsx b/apps/web/components/kanban-card-context-menu.tsx new file mode 100644 index 0000000000..ca1928e9e5 --- /dev/null +++ b/apps/web/components/kanban-card-context-menu.tsx @@ -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 ( + + {children} + + + + + ); +} diff --git a/apps/web/components/kanban-card-menu-items.tsx b/apps/web/components/kanban-card-menu-items.tsx new file mode 100644 index 0000000000..1c42863264 --- /dev/null +++ b/apps/web/components/kanban-card-menu-items.tsx @@ -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; +}; + +type BuildKanbanCardMenuEntriesArgs = { + currentWorkflowId?: string | null; + currentStepId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; + 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 ( + + {isCurrent && Current} + {hasAutoStart && ( + Auto-start + )} + + ); +} + +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: , + label: {step.title}, + trailing: , + 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: , + 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: {workflow.name}, + trailing: ( + + No steps + + ), + }; + } + + return { + kind: "submenu", + key: `workflow-${workflow.id}`, + testId: `task-context-workflow-${workflow.id}`, + label: {workflow.name}, + 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; + 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: , + 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: , + 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 ? ( + + ) : ( + + ), + label: "Archive", + disabled: isProcessing || !onArchive, + onSelect: onArchive, + }); + + entries.push({ kind: "separator", key: "delete-separator" }); + entries.push({ + kind: "item", + key: "delete", + icon: isDeleting ? ( + + ) : ( + + ), + 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(() => { + 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>(() => { + const result: Record = {}; + 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 ; + if (entry.kind === "submenu") { + return ( + + + {entry.icon} + {entry.label} + + + {entry.children.map((child) => ( + + ))} + + + ); + } + + return ( + { + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + + ); +} + +function DropdownEntry({ entry }: { entry: KanbanCardMenuEntry }) { + if (entry.kind === "separator") return ; + if (entry.kind === "submenu") { + return ( + + event.stopPropagation()} + onPointerDown={(event) => event.stopPropagation()} + > + {entry.icon} + {entry.label} + + + + {entry.children.map((child) => ( + + ))} + + + + ); + } + + return ( + { + event.stopPropagation(); + if (!entry.disabled) entry.onSelect?.(); + }} + > + {entry.icon} + {entry.leading} + {entry.label} + {entry.trailing} + + ); +} + +export function KanbanCardContextMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} + +export function KanbanCardDropdownMenuItems({ entries }: { entries: KanbanCardMenuEntry[] }) { + return ( + <> + {entries.map((entry) => ( + + ))} + + ); +} diff --git a/apps/web/components/kanban-card-preview.tsx b/apps/web/components/kanban-card-preview.tsx new file mode 100644 index 0000000000..395e4f39c7 --- /dev/null +++ b/apps/web/components/kanban-card-preview.tsx @@ -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 ( + + + + + + ); +} + +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 ; +} diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 2f6dd0801c..b983dbe798 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -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 }>; + on_turn_start?: Array<{ type: string; config?: Record }>; + on_turn_complete?: Array<{ type: string; config?: Record }>; + on_exit?: Array<{ type: string; config?: Record }>; + }; } interface KanbanCardProps { @@ -80,383 +64,100 @@ interface KanbanCardProps { isDeleting?: boolean; isArchiving?: boolean; isSelected?: boolean; + selectedIds?: Set; 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 = ( -
- {visible.map((name) => ( - - {name} - - ))} - {overflow.length > 0 && ( - - +{overflow.length} - - )} -
- ); - if (repoNames.length <= 1) return row; - return ( - - {row} - -
- {repoNames.map((name) => ( - {name} - ))} -
-
-
- ); -} - -function KanbanCardBody({ +function useKanbanCardMenus({ task, - repoNames, - actions, -}: { - task: Task; - repoNames: string[]; - actions?: React.ReactNode; -}) { - return ( - <> -
-
- -
-

- {task.title} -

- -
-
- {task.isRemoteExecutor && ( - - )} - {actions} -
- {task.description && ( -

- {task.description} -

- )} - - - ); -} - -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 ( -
- {task.parentTaskId && ( - - - {parentTitle ?? "Subtask"} - - )} - {task.sessionCount && task.sessionCount > 1 && ( - - {task.sessionCount} sessions - - )} - {task.reviewStatus === "pending" && task.state !== "IN_PROGRESS" && ( -
- - Approval Required -
- )} - {task.reviewStatus === "changes_requested" && ( - - Changes Requested - - )} -
- ); -} - -function KanbanCardLayout({ - task, - repositoryNames, - className, -}: KanbanCardProps & { className?: string }) { - return ( - - - - - - ); -} - -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 ( -
- {(task.state === "IN_PROGRESS" || task.state === "SCHEDULING" || showQuestionIcon) && - statusIcon} - {showMaximizeButton && onOpenFullPage && hasKnownSession && ( - - )} - -
- ); -} - -function MoveToSubmenu({ - task, - steps, - disabled, - onMove, -}: { - task: Task; - steps: WorkflowStep[]; - disabled?: boolean; - onMove: (task: Task, targetStepId: string) => void; -}) { - return ( - - event.stopPropagation()} - onPointerDown={(event) => event.stopPropagation()} - > - Move to - - - - {steps - .filter((col) => col.id !== task.workflowStepId) - .map((col) => ( - { - event.stopPropagation(); - onMove(task, col.id); - }} - > -
- {col.title} - - ))} - - - - ); -} - -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 ( - <> - { - if (!open && isProcessing) return; - setMenuOpen(open); - }} - > - - - - - { - e.stopPropagation(); - onEdit?.(task); - }} - > - Edit - - {steps && steps.length > 1 && onMove && ( - - )} - {onArchive && ( - { - e.stopPropagation(); - setShowArchiveConfirm(true); - }} - > - {isArchiving ? : null} - Archive - - )} - - { - e.stopPropagation(); - setShowDeleteConfirm(true); - }} - > - Delete - - - - onDelete?.(task)} - /> - { - 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 ( -
e.stopPropagation()} - data-testid={`task-select-checkbox-${taskId}`} - > - -
- ); + 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 ( - - -
- {showCheckbox && ( - - )} -
- - ) : undefined - } - /> -
-
-
-
- ); -} - -export function KanbanCardPreview({ task }: KanbanCardProps) { - const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); - const repositoryNames = useMemo( - () => resolveTaskRepositoryNames(task, Object.values(repositoriesByWorkspace).flat()), - [repositoriesByWorkspace, task], - ); - - return ( - + <> + + + + onDelete?.(task)} + /> + onArchive?.(task)} + /> + ); } diff --git a/apps/web/components/kanban-column.tsx b/apps/web/components/kanban-column.tsx index ed2c794656..96e203e87c 100644 --- a/apps/web/components/kanban-column.tsx +++ b/apps/web/components/kanban-column.tsx @@ -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} /> diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 9ec114b1f7..eabc9e904b 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -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"; diff --git a/apps/web/components/task/task-move-context-menu.tsx b/apps/web/components/task/task-move-context-menu.tsx new file mode 100644 index 0000000000..52278792ca --- /dev/null +++ b/apps/web/components/task/task-move-context-menu.tsx @@ -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 }> }; +}; + +export type TaskMoveWorkflow = { + id: string; + name: string; + hidden?: boolean; +}; + +type TaskMoveContextMenuItemsProps = { + currentWorkflowId?: string | null; + currentStepId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; + 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 ( + { + event.preventDefault(); + if (!isCurrent) onSelect(step.id); + }} + > + + {step.title} + {(isCurrent || hasAutoStart) && ( + + {isCurrent && Current} + {hasAutoStart && ( + Auto-start + )} + + )} + + ); +} + +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 ( + + + + Move to + + + {steps.map((step) => ( + + ))} + + + ); +} + +function WorkflowTargetItem({ + workflow, + steps, + disabled, + onSendToWorkflow, +}: { + workflow: TaskMoveWorkflow; + steps: TaskMoveStep[]; + disabled?: boolean; + onSendToWorkflow?: (workflowId: string, stepId: string) => void; +}) { + if (steps.length === 0 || !onSendToWorkflow) { + return ( + + {workflow.name} + + No steps + + + ); + } + + return ( + + + {workflow.name} + + + {steps.map((step) => ( + onSendToWorkflow(workflow.id, stepId)} + /> + ))} + + + ); +} + +function SendToWorkflowSubmenu({ + currentWorkflowId, + workflows, + stepsByWorkflowId, + disabled, + onSendToWorkflow, +}: { + currentWorkflowId?: string | null; + workflows: TaskMoveWorkflow[]; + stepsByWorkflowId: Record; + 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 ( + + + + Send to workflow + + + {targets.map((workflow) => ( + + ))} + + + ); +} + +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 && } + + + + ); +} diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index a74430ec8a..29525c6106 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -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 }> }; +}; type SidebarItem = Omit, "workflowId"> & { workflowId?: string }; function buildArchivedItem(s: ReturnType): 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({ }> }; +}; type TaskSwitcherProps = { grouped: GroupedSidebarList; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; activeTaskId: string | null; selectedTaskId: string | null; @@ -132,6 +139,7 @@ type TaskRowProps = { task: TaskSwitcherItem; isSubTask?: boolean; subtaskToggle?: SubtaskToggleInfo; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; 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 ( ; + workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; 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; 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 ( @@ -330,15 +353,26 @@ function TaskItemWithContextMenu({ Archive )} - {onMoveToStep && task.workflowId && steps && steps.length > 0 && ( - { - 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 ( - <> - - - - - Move to - - - {steps.map((step) => ( - { - e.preventDefault(); - onSelect(step.id); - }} - > - - {step.title} - {step.id === currentStepId && ( - Current - )} - - ))} - - - - ); -} - 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} diff --git a/apps/web/e2e/pages/kanban-page.ts b/apps/web/e2e/pages/kanban-page.ts index 3b1291c06c..58e702804d 100644 --- a/apps/web/e2e/pages/kanban-page.ts +++ b/apps/web/e2e/pages/kanban-page.ts @@ -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 diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts index 5af7bffaec..022e8b6edb 100644 --- a/apps/web/e2e/pages/session-page.ts +++ b/apps/web/e2e/pages/session-page.ts @@ -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 { + 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 { + 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". diff --git a/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts b/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts new file mode 100644 index 0000000000..8b01931b6a --- /dev/null +++ b/apps/web/e2e/tests/kanban/cross-workflow-task-batch.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts b/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts new file mode 100644 index 0000000000..7cacc51b77 --- /dev/null +++ b/apps/web/e2e/tests/kanban/cross-workflow-task-move.spec.ts @@ -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(); + }); +}); diff --git a/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts b/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts new file mode 100644 index 0000000000..fd67fdcf23 --- /dev/null +++ b/apps/web/e2e/tests/task/sidebar-send-to-workflow.spec.ts @@ -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, + }); + }); +}); diff --git a/apps/web/hooks/use-task-workflow-move.test.ts b/apps/web/hooks/use-task-workflow-move.test.ts new file mode 100644 index 0000000000..0cc6c88107 --- /dev/null +++ b/apps/web/hooks/use-task-workflow-move.test.ts @@ -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", + }); + }); +}); diff --git a/apps/web/hooks/use-task-workflow-move.ts b/apps/web/hooks/use-task-workflow-move.ts new file mode 100644 index 0000000000..a5f21c0ad1 --- /dev/null +++ b/apps/web/hooks/use-task-workflow-move.ts @@ -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], + ); +} diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index ad4fecb9b0..56a83099c9 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -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(`/api/v1/tasks/${taskId}`, options); } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index fdb011af44..ceb1bc4ec7 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -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; diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index abad6aa08a..bcb59f2fb4 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -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({ diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 1374fb0c27..82def7acd7 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -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): 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: