From 3af7a692bf28b7d9f32f2c67d93ce6742b6b70a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 16 Feb 2026 21:46:25 +0000 Subject: [PATCH] feat: archive tasks (#232) --- apps/backend/cmd/kandev/main.go | 3 + .../repository/sqlite/repository_test.go | 2 + .../analytics/repository/sqlite/stats.go | 12 +- .../orchestrator/executor/executor_test.go | 6 +- apps/backend/internal/task/controller/task.go | 33 ++- .../internal/task/controller/workflow.go | 30 +-- apps/backend/internal/task/dto/dto.go | 23 +- apps/backend/internal/task/dto/requests.go | 13 +- .../task/handlers/process_handlers_test.go | 8 +- .../internal/task/handlers/task_handlers.go | 42 ++- apps/backend/internal/task/models/models.go | 1 + .../internal/task/repository/interface.go | 4 +- .../internal/task/repository/sqlite/base.go | 41 +++ .../internal/task/repository/sqlite/task.go | 89 +++++-- .../internal/task/repository/sqlite_test.go | 250 +++++++++++++++++- .../internal/task/service/auto_archive.go | 50 ++++ apps/backend/internal/task/service/service.go | 110 +++++++- .../workflow/controller/controller.go | 20 +- .../internal/workflow/models/models.go | 40 +-- .../internal/workflow/repository/sqlite.go | 57 +++- apps/backend/pkg/websocket/actions.go | 1 + apps/web/app/actions/workspaces.ts | 5 +- apps/web/app/tasks/columns.tsx | 171 ++++++------ apps/web/app/tasks/tasks-page-client.tsx | 81 ++++-- .../settings/workflow-pipeline-editor.tsx | 33 +++ apps/web/components/task/changes-panel.tsx | 4 + apps/web/components/task/files-panel.tsx | 4 + .../mobile/session-task-switcher-sheet.tsx | 84 ++---- .../components/task/task-archived-context.tsx | 36 +++ .../components/task/task-changes-panel.tsx | 4 + apps/web/components/task/task-chat-panel.tsx | 8 + apps/web/components/task/task-item-menu.tsx | 13 +- apps/web/components/task/task-item.tsx | 13 +- .../web/components/task/task-page-content.tsx | 36 ++- .../components/task/task-session-sidebar.tsx | 119 +++------ apps/web/components/task/task-switcher.tsx | 5 + apps/web/components/task/task-top-bar.tsx | 124 +++++---- apps/web/components/task/terminal-panel.tsx | 4 + apps/web/components/task/workflow-stepper.tsx | 16 +- apps/web/hooks/use-task-actions.ts | 8 +- apps/web/hooks/use-task-removal.ts | 119 +++++++++ apps/web/lib/api/domains/kanban-api.ts | 10 +- apps/web/lib/types/backend.ts | 1 + apps/web/lib/types/http.ts | 3 + apps/web/lib/ws/handlers/tasks.ts | 35 ++- 45 files changed, 1311 insertions(+), 460 deletions(-) create mode 100644 apps/backend/internal/task/service/auto_archive.go create mode 100644 apps/web/components/task/task-archived-context.tsx create mode 100644 apps/web/hooks/use-task-removal.ts diff --git a/apps/backend/cmd/kandev/main.go b/apps/backend/cmd/kandev/main.go index aca00c2109..6c971929c3 100644 --- a/apps/backend/cmd/kandev/main.go +++ b/apps/backend/cmd/kandev/main.go @@ -391,6 +391,9 @@ func main() { } log.Info("Orchestrator initialized") + // Start auto-archive background loop + taskSvc.StartAutoArchiveLoop(ctx) + // Subscribe to message.added events and broadcast to WebSocket subscribers _, err = eventBus.Subscribe(events.MessageAdded, func(ctx context.Context, event *bus.Event) error { data, ok := event.Data.(map[string]interface{}) diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index 7e536a66af..a80d119d72 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -44,6 +44,7 @@ func createTestDB(t *testing.T) *sql.DB { prompt TEXT, events TEXT, allow_manual_move INTEGER DEFAULT 1, + auto_archive_after_hours INTEGER DEFAULT 0, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); @@ -54,6 +55,7 @@ func createTestDB(t *testing.T) *sql.DB { workflow_step_id TEXT NOT NULL DEFAULT '', title TEXT NOT NULL, state TEXT DEFAULT 'TODO', + archived_at DATETIME, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 869d432d8f..896818ba3d 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -152,11 +152,12 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta SELECT (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND (? IS NULL OR created_at >= ?)) as total_tasks, (SELECT COUNT(*) FROM tasks t - JOIN workflow_steps ws ON ws.id = t.workflow_step_id + LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id WHERE t.workspace_id = ? - AND ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) + AND (t.archived_at IS NOT NULL + OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) AND (? IS NULL OR t.created_at >= ?)) as completed_tasks, - (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND state = 'IN_PROGRESS' AND (? IS NULL OR created_at >= ?)) as in_progress_tasks, + (SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND state = 'IN_PROGRESS' AND archived_at IS NULL AND (? IS NULL OR created_at >= ?)) as in_progress_tasks, (SELECT COUNT(*) FROM task_sessions s JOIN tasks t ON t.id = s.task_id WHERE t.workspace_id = ? AND (? IS NULL OR s.started_at >= ?)) as total_sessions, (SELECT COUNT(*) FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id @@ -298,7 +299,7 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s date(ts.completed_at) as activity_date, COUNT(DISTINCT t.id) as completed_tasks FROM tasks t - JOIN workflow_steps ws ON ws.id = t.workflow_step_id + LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id JOIN ( SELECT task_id, MAX(completed_at) as completed_at FROM task_sessions @@ -306,7 +307,8 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s GROUP BY task_id ) ts ON ts.task_id = t.id WHERE t.workspace_id = ? - AND ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id) + AND (t.archived_at IS NOT NULL + OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) GROUP BY date(ts.completed_at) ) activity ON activity.activity_date = d.date ORDER BY d.date ASC diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 8c02005ce4..8445363861 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -168,6 +168,10 @@ func (m *mockRepository) CreateTaskSessionWorktree(ctx context.Context, worktree func (m *mockRepository) UpdateTaskState(ctx context.Context, taskID string, state v1.TaskState) error { return nil } +func (m *mockRepository) ArchiveTask(ctx context.Context, id string) error { return nil } +func (m *mockRepository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { + return nil, nil +} func (m *mockRepository) GetWorkspace(ctx context.Context, id string) (*models.Workspace, error) { return nil, nil @@ -197,7 +201,7 @@ func (m *mockRepository) DeleteTask(ctx context.Context, id string) error func (m *mockRepository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { return nil, nil } -func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { return nil, 0, nil } func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index 7742f07aa6..876e2e57b9 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -35,7 +35,7 @@ func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest } func (c *TaskController) ListTasksByWorkspace(ctx context.Context, req dto.ListTasksByWorkspaceRequest) (dto.ListTasksResponse, error) { - tasks, total, err := c.service.ListTasksByWorkspace(ctx, req.WorkspaceID, req.Query, req.Page, req.PageSize) + tasks, total, err := c.service.ListTasksByWorkspace(ctx, req.WorkspaceID, req.Query, req.Page, req.PageSize, req.IncludeArchived) if err != nil { return dto.ListTasksResponse{}, err } @@ -178,6 +178,13 @@ func (c *TaskController) DeleteTask(ctx context.Context, req dto.DeleteTaskReque return dto.SuccessResponse{Success: true}, nil } +func (c *TaskController) ArchiveTask(ctx context.Context, req dto.ArchiveTaskRequest) (dto.SuccessResponse, error) { + if err := c.service.ArchiveTask(ctx, req.ID); err != nil { + return dto.SuccessResponse{}, err + } + return dto.SuccessResponse{Success: true}, nil +} + func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) (dto.MoveTaskResponse, error) { result, err := c.service.MoveTask(ctx, req.ID, req.WorkflowID, req.WorkflowStepID, req.Position) if err != nil { @@ -190,7 +197,7 @@ func (c *TaskController) MoveTask(ctx context.Context, req dto.MoveTaskRequest) // Include workflow step info if available if result.WorkflowStep != nil { - response.WorkflowStep = serviceStepToDTO(result.WorkflowStep) + response.WorkflowStep = WorkflowStepToDTO(result.WorkflowStep) } return response, nil @@ -257,22 +264,24 @@ func (c *TaskController) ApproveSession(ctx context.Context, req dto.ApproveSess // Include the new workflow step if present if result.WorkflowStep != nil { - resp.WorkflowStep = serviceStepToDTO(result.WorkflowStep) + resp.WorkflowStep = WorkflowStepToDTO(result.WorkflowStep) } return resp, nil } -// serviceStepToDTO converts a workflow step to a DTO. -func serviceStepToDTO(step *wfmodels.WorkflowStep) dto.WorkflowStepDTO { +// WorkflowStepToDTO converts a workflow step to a DTO. +// Shared by both task and workflow controllers. +func WorkflowStepToDTO(step *wfmodels.WorkflowStep) dto.WorkflowStepDTO { result := dto.WorkflowStepDTO{ - ID: step.ID, - WorkflowID: step.WorkflowID, - Name: step.Name, - Position: step.Position, - Color: step.Color, - Prompt: step.Prompt, - AllowManualMove: step.AllowManualMove, + ID: step.ID, + WorkflowID: step.WorkflowID, + Name: step.Name, + Position: step.Position, + Color: step.Color, + Prompt: step.Prompt, + AllowManualMove: step.AllowManualMove, + AutoArchiveAfterHours: step.AutoArchiveAfterHours, } if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { events := &dto.StepEventsDTO{} diff --git a/apps/backend/internal/task/controller/workflow.go b/apps/backend/internal/task/controller/workflow.go index f011489a50..6bbe9d9c06 100644 --- a/apps/backend/internal/task/controller/workflow.go +++ b/apps/backend/internal/task/controller/workflow.go @@ -32,33 +32,9 @@ func (c *WorkflowController) SetWorkflowStepLister(lister WorkflowStepLister) { // toWorkflowStepDTO converts a workflow step to a WorkflowStepDTO. func toWorkflowStepDTO(step *workflowmodels.WorkflowStep) dto.WorkflowStepDTO { - result := dto.WorkflowStepDTO{ - ID: step.ID, - WorkflowID: step.WorkflowID, - Name: step.Name, - Position: step.Position, - Color: step.Color, - Prompt: step.Prompt, - AllowManualMove: step.AllowManualMove, - CreatedAt: step.CreatedAt, - UpdatedAt: step.UpdatedAt, - } - if len(step.Events.OnEnter) > 0 || len(step.Events.OnTurnComplete) > 0 { - events := &dto.StepEventsDTO{} - for _, a := range step.Events.OnEnter { - events.OnEnter = append(events.OnEnter, dto.StepActionDTO{ - Type: string(a.Type), - Config: a.Config, - }) - } - for _, a := range step.Events.OnTurnComplete { - events.OnTurnComplete = append(events.OnTurnComplete, dto.StepActionDTO{ - Type: string(a.Type), - Config: a.Config, - }) - } - result.Events = events - } + result := WorkflowStepToDTO(step) + result.CreatedAt = step.CreatedAt + result.UpdatedAt = step.UpdatedAt return result } diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 1a6e7038d7..0bf5d0135a 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -99,6 +99,7 @@ type TaskDTO struct { PrimarySessionID *string `json:"primary_session_id,omitempty"` SessionCount *int `json:"session_count,omitempty"` ReviewStatus *string `json:"review_status,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Metadata map[string]interface{} `json:"metadata,omitempty"` @@ -399,6 +400,7 @@ func FromTaskWithSessionInfo(task *models.Task, primarySessionID *string, sessio PrimarySessionID: primarySessionID, SessionCount: sessionCount, ReviewStatus: reviewStatus, + ArchivedAt: task.ArchivedAt, CreatedAt: task.CreatedAt, UpdatedAt: task.UpdatedAt, Metadata: task.Metadata, @@ -441,16 +443,17 @@ func FromTaskSession(session *models.TaskSession) TaskSessionDTO { // WorkflowStepDTO represents a workflow step for API responses type WorkflowStepDTO struct { - ID string `json:"id"` - WorkflowID string `json:"workflow_id"` - Name string `json:"name"` - Position int `json:"position"` - Color string `json:"color"` - Prompt string `json:"prompt,omitempty"` - Events *StepEventsDTO `json:"events,omitempty"` - AllowManualMove bool `json:"allow_manual_move"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events *StepEventsDTO `json:"events,omitempty"` + AllowManualMove bool `json:"allow_manual_move"` + AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // StepEventsDTO represents step events for API responses diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 143def7f65..704b3b4f63 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -221,10 +221,15 @@ type ListTasksRequest struct { } type ListTasksByWorkspaceRequest struct { - WorkspaceID string - Query string - Page int - PageSize int + WorkspaceID string + Query string + Page int + PageSize int + IncludeArchived bool +} + +type ArchiveTaskRequest struct { + ID string } type ListTaskSessionsRequest struct { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index 97370738c8..668789785c 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -60,12 +60,18 @@ func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { func (m *mockRepository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { return nil, nil } -func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { return nil, 0, nil } func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { return nil, nil } +func (m *mockRepository) ArchiveTask(ctx context.Context, id string) error { + return nil +} +func (m *mockRepository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { + return nil, nil +} func (m *mockRepository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { return nil } diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index c27f4509e8..c1f2a12af8 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -72,6 +72,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.PATCH("/tasks/:id", h.httpUpdateTask) api.POST("/tasks/:id/move", h.httpMoveTask) api.DELETE("/tasks/:id", h.httpDeleteTask) + api.POST("/tasks/:id/archive", h.httpArchiveTask) api.POST("/tasks/bulk-move", h.httpBulkMoveTasks) api.GET("/workflows/:id/task-count", h.httpGetWorkflowTaskCount) @@ -89,6 +90,7 @@ func (h *TaskHandlers) registerWS(dispatcher *ws.Dispatcher) { dispatcher.RegisterFunc(ws.ActionTaskDelete, h.wsDeleteTask) dispatcher.RegisterFunc(ws.ActionTaskMove, h.wsMoveTask) dispatcher.RegisterFunc(ws.ActionTaskState, h.wsUpdateTaskState) + dispatcher.RegisterFunc(ws.ActionTaskArchive, h.wsArchiveTask) dispatcher.RegisterFunc(ws.ActionTaskSessionList, h.wsListTaskSessions) // Git snapshot and commit handlers dispatcher.RegisterFunc(ws.ActionSessionGitSnapshots, h.wsGetGitSnapshots) @@ -132,12 +134,14 @@ func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { } query := c.Query("query") + includeArchived := c.Query("include_archived") == "true" resp, err := h.controller.ListTasksByWorkspace(c.Request.Context(), dto.ListTasksByWorkspaceRequest{ - WorkspaceID: c.Param("id"), - Query: query, - Page: page, - PageSize: pageSize, + WorkspaceID: c.Param("id"), + Query: query, + Page: page, + PageSize: pageSize, + IncludeArchived: includeArchived, }) if err != nil { handleNotFound(c, h.logger, err, "tasks not found") @@ -480,6 +484,15 @@ func (h *TaskHandlers) httpDeleteTask(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *TaskHandlers) httpArchiveTask(c *gin.Context) { + resp, err := h.controller.ArchiveTask(c.Request.Context(), dto.ArchiveTaskRequest{ID: c.Param("id")}) + if err != nil { + handleNotFound(c, h.logger, err, "task not archived") + return + } + c.JSON(http.StatusOK, resp) +} + // WS handlers type wsListTaskSessionsRequest struct { @@ -703,6 +716,27 @@ func (h *TaskHandlers) wsDeleteTask(ctx context.Context, msg *ws.Message) (*ws.M return ws.NewResponse(msg.ID, msg.Action, resp) } +type wsArchiveTaskRequest struct { + ID string `json:"id"` +} + +func (h *TaskHandlers) wsArchiveTask(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req wsArchiveTaskRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.ID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "id is required", nil) + } + + resp, err := h.controller.ArchiveTask(ctx, dto.ArchiveTaskRequest{ID: req.ID}) + if err != nil { + h.logger.Error("failed to archive task", zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to archive task", nil) + } + return ws.NewResponse(msg.ID, msg.Action, resp) +} + type wsMoveTaskRequest struct { ID string `json:"id"` WorkflowID string `json:"workflow_id"` diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 39e69f3f28..c274620c41 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -28,6 +28,7 @@ type Task struct { Position int `json:"position"` // Order within workflow step Metadata map[string]interface{} `json:"metadata,omitempty"` Repositories []*TaskRepository `json:"repositories,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index 223a188071..0b7b04f9d8 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -22,8 +22,10 @@ type Repository interface { UpdateTask(ctx context.Context, task *models.Task) error DeleteTask(ctx context.Context, id string) error ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) - ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) + ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) + ArchiveTask(ctx context.Context, id string) error + ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) diff --git a/apps/backend/internal/task/repository/sqlite/base.go b/apps/backend/internal/task/repository/sqlite/base.go index e3b963485c..3cea65dc5a 100644 --- a/apps/backend/internal/task/repository/sqlite/base.go +++ b/apps/backend/internal/task/repository/sqlite/base.go @@ -398,5 +398,46 @@ func (r *Repository) initSchema() error { return err } + if err := r.ensureArchivedAtColumn(); err != nil { + return err + } + return nil } + +// ensureArchivedAtColumn adds the archived_at column to tasks if it doesn't exist. +func (r *Repository) ensureArchivedAtColumn() error { + rows, err := r.db.Query("PRAGMA table_info(tasks)") + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + var hasColumn bool + for rows.Next() { + var cid int + var name, colType string + var notNull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + return err + } + if name == "archived_at" { + hasColumn = true + break + } + } + if err := rows.Err(); err != nil { + return err + } + if hasColumn { + return nil + } + + if _, err := r.db.Exec("ALTER TABLE tasks ADD COLUMN archived_at DATETIME"); err != nil { + return err + } + _, err = r.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)") + return err +} diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 5a52168497..e32c8e3d6d 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -50,10 +50,11 @@ func (r *Repository) CreateTask(ctx context.Context, task *models.Task) error { func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, error) { task := &models.Task{} var metadata string + var archivedAt sql.NullTime err := r.db.QueryRowContext(ctx, ` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks WHERE id = ? - `, id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &task.CreatedAt, &task.UpdatedAt) + `, id).Scan(&task.ID, &task.WorkspaceID, &task.WorkflowID, &task.WorkflowStepID, &task.Title, &task.Description, &task.State, &task.Priority, &task.Position, &metadata, &archivedAt, &task.CreatedAt, &task.UpdatedAt) if err == sql.ErrNoRows { return nil, fmt.Errorf("task not found: %s", id) @@ -62,6 +63,9 @@ func (r *Repository) GetTask(ctx context.Context, id string) (*models.Task, erro return nil, err } + if archivedAt.Valid { + task.ArchivedAt = &archivedAt.Time + } _ = json.Unmarshal([]byte(metadata), &task.Metadata) return task, nil } @@ -104,12 +108,12 @@ func (r *Repository) DeleteTask(ctx context.Context, id string) error { return nil } -// ListTasks returns all tasks for a workflow +// ListTasks returns all non-archived tasks for a workflow func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks - WHERE workflow_id = ? + WHERE workflow_id = ? AND archived_at IS NULL ORDER BY position `, workflowID) if err != nil { @@ -120,32 +124,32 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model return r.scanTasks(rows) } -// CountTasksByWorkflow returns the number of tasks in a workflow +// CountTasksByWorkflow returns the number of non-archived tasks in a workflow func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { var count int - err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_id = ?`, workflowID).Scan(&count) + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL`, workflowID).Scan(&count) if err != nil { return 0, err } return count, nil } -// CountTasksByWorkflowStep returns the number of tasks in a workflow step +// CountTasksByWorkflowStep returns the number of non-archived tasks in a workflow step func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { var count int - err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ?`, stepID).Scan(&count) + err := r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL`, stepID).Scan(&count) if err != nil { return 0, err } return count, nil } -// ListTasksByWorkflowStep returns all tasks in a workflow step +// ListTasksByWorkflowStep returns all non-archived tasks in a workflow step func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks - WHERE workflow_step_id = ? ORDER BY position + WHERE workflow_step_id = ? AND archived_at IS NULL ORDER BY position `, workflowStepID) if err != nil { return nil, err @@ -157,28 +161,34 @@ func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID // ListTasksByWorkspace returns paginated tasks for a workspace with total count // If query is non-empty, filters by task title, description, repository name, or repository path -func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { +// If includeArchived is false, archived tasks are excluded +func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { // Calculate offset offset := (page - 1) * pageSize if offset < 0 { offset = 0 } + archiveFilter := "" + if !includeArchived { + archiveFilter = " AND archived_at IS NULL" + } + var total int var rows *sql.Rows var err error if query == "" { // No search query - use simple query - err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`, workspaceID).Scan(&total) + err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`+archiveFilter, workspaceID).Scan(&total) if err != nil { return nil, 0, err } rows, err = r.db.QueryContext(ctx, ` - SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, archived_at, created_at, updated_at FROM tasks - WHERE workspace_id = ? + WHERE workspace_id = ?`+archiveFilter+` ORDER BY updated_at DESC LIMIT ? OFFSET ? `, workspaceID, pageSize, offset) @@ -186,11 +196,16 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin // Search query - use JOIN with repositories searchPattern := "%" + query + "%" + tFilter := "" + if !includeArchived { + tFilter = " AND t.archived_at IS NULL" + } + err = r.db.QueryRowContext(ctx, ` SELECT COUNT(DISTINCT t.id) FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ? + WHERE t.workspace_id = ?`+tFilter+` AND ( t.title LIKE ? OR t.description LIKE ? OR @@ -203,11 +218,11 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin } rows, err = r.db.QueryContext(ctx, ` - SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.created_at, t.updated_at + SELECT DISTINCT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at FROM tasks t LEFT JOIN task_repositories tr ON t.id = tr.task_id LEFT JOIN repositories r ON tr.repository_id = r.id - WHERE t.workspace_id = ? + WHERE t.workspace_id = ?`+tFilter+` AND ( t.title LIKE ? OR t.description LIKE ? OR @@ -238,6 +253,7 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { for rows.Next() { task := &models.Task{} var metadata string + var archivedAt sql.NullTime err := rows.Scan( &task.ID, &task.WorkspaceID, @@ -249,18 +265,53 @@ func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { &task.Priority, &task.Position, &metadata, + &archivedAt, &task.CreatedAt, &task.UpdatedAt, ) if err != nil { return nil, err } + if archivedAt.Valid { + task.ArchivedAt = &archivedAt.Time + } _ = json.Unmarshal([]byte(metadata), &task.Metadata) result = append(result, task) } return result, rows.Err() } +// ArchiveTask sets the archived_at timestamp on a task +func (r *Repository) ArchiveTask(ctx context.Context, id string) error { + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, `UPDATE tasks SET archived_at = ?, updated_at = ? WHERE id = ?`, now, now, id) + if err != nil { + return err + } + rows, _ := result.RowsAffected() + if rows == 0 { + return fmt.Errorf("task not found: %s", id) + } + return nil +} + +// ListTasksForAutoArchive returns tasks eligible for auto-archiving based on workflow step settings +func (r *Repository) ListTasksForAutoArchive(ctx context.Context) ([]*models.Task, error) { + rows, err := r.db.QueryContext(ctx, ` + SELECT t.id, t.workspace_id, t.workflow_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.archived_at, t.created_at, t.updated_at + FROM tasks t + JOIN workflow_steps ws ON ws.id = t.workflow_step_id + WHERE ws.auto_archive_after_hours > 0 + AND t.archived_at IS NULL + AND t.updated_at <= datetime('now', '-' || ws.auto_archive_after_hours || ' hours') + `) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + return r.scanTasks(rows) +} + // UpdateTaskState updates the state of a task func (r *Repository) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error { result, err := r.db.ExecContext(ctx, `UPDATE tasks SET state = ?, updated_at = ? WHERE id = ?`, state, time.Now().UTC(), id) diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 0eb06ad0ba..1c07ee41dd 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -370,7 +370,7 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", WorkflowID: "wf-456", WorkflowStepID: "step-2", Title: "Task Four"}) // Test basic listing without search - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10) + tasks, total, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 10, false) if err != nil { t.Fatalf("failed to list tasks by workspace: %v", err) } @@ -382,7 +382,7 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { } // Test pagination - tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 2) + tasks, total, err = repo.ListTasksByWorkspace(ctx,"ws-1", "", 1, 2, false) if err != nil { t.Fatalf("failed to list tasks with pagination: %v", err) } @@ -394,7 +394,7 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { } // Test page 2 - tasksPage2, _, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 2, 2) + tasksPage2, _, err := repo.ListTasksByWorkspace(ctx,"ws-1", "", 2, 2, false) if err != nil { t.Fatalf("failed to list tasks page 2: %v", err) } @@ -424,7 +424,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main"}) // Test search by title - _, totalAuth, err := repo.ListTasksByWorkspace(ctx, "ws-1", "authentication", 1, 10) + _, totalAuth, err := repo.ListTasksByWorkspace(ctx,"ws-1", "authentication", 1, 10, false) if err != nil { t.Fatalf("failed to search tasks by title: %v", err) } @@ -433,7 +433,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by description - tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx, "ws-1", "dark mode", 1, 10) + tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx,"ws-1", "dark mode", 1, 10, false) if err != nil { t.Fatalf("failed to search tasks by description: %v", err) } @@ -445,7 +445,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by repository name - tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx, "ws-1", "MyProject", 1, 10) + tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx,"ws-1", "MyProject", 1, 10, false) if err != nil { t.Fatalf("failed to search tasks by repository name: %v", err) } @@ -457,7 +457,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by repository local_path - _, totalPath, err := repo.ListTasksByWorkspace(ctx, "ws-1", "myproject", 1, 10) + _, totalPath, err := repo.ListTasksByWorkspace(ctx,"ws-1", "myproject", 1, 10, false) if err != nil { t.Fatalf("failed to search tasks by repository path: %v", err) } @@ -466,7 +466,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search with no results - tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx, "ws-1", "nonexistent", 1, 10) + tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx,"ws-1", "nonexistent", 1, 10, false) if err != nil { t.Fatalf("failed to search tasks with no results: %v", err) } @@ -1150,3 +1150,237 @@ func TestSQLiteRepository_CompleteRunningToolCallsForTurn(t *testing.T) { t.Errorf("expected 0 affected rows on second call, got %d", affected2) } } + +func TestSQLiteRepository_ArchiveTask(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) + + // Archive the task + err := repo.ArchiveTask(ctx, "task-1") + if err != nil { + t.Fatalf("failed to archive task: %v", err) + } + + // Verify archived_at is set + task, err := repo.GetTask(ctx, "task-1") + if err != nil { + t.Fatalf("failed to get task: %v", err) + } + if task.ArchivedAt == nil { + t.Fatal("expected archived_at to be set") + } + + // Archive again should fail + err = repo.ArchiveTask(ctx, "task-1") + // The repo method doesn't check for already archived, but it still succeeds (updates the timestamp) + if err != nil { + t.Fatalf("unexpected error archiving already-archived task: %v", err) + } + + // Archive non-existent task should fail + err = repo.ArchiveTask(ctx, "nonexistent") + if err == nil { + t.Error("expected error when archiving nonexistent task") + } +} + +func TestSQLiteRepository_ArchiveTask_ExcludesFromListTasks(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task One"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Task Two"}) + + // Archive task-1 + _ = repo.ArchiveTask(ctx, "task-1") + + // ListTasks should exclude archived tasks + tasks, err := repo.ListTasks(ctx, "wf-1") + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if len(tasks) != 1 { + t.Errorf("expected 1 task (excluding archived), got %d", len(tasks)) + } + if tasks[0].ID != "task-2" { + t.Errorf("expected task-2, got %s", tasks[0].ID) + } + + // ListTasksByWorkflowStep should exclude archived tasks + tasksByStep, err := repo.ListTasksByWorkflowStep(ctx, "step-1") + if err != nil { + t.Fatalf("failed to list tasks by step: %v", err) + } + if len(tasksByStep) != 1 { + t.Errorf("expected 1 task by step (excluding archived), got %d", len(tasksByStep)) + } + + // CountTasksByWorkflow should exclude archived tasks + count, err := repo.CountTasksByWorkflow(ctx, "wf-1") + if err != nil { + t.Fatalf("failed to count tasks: %v", err) + } + if count != 1 { + t.Errorf("expected count 1, got %d", count) + } + + // CountTasksByWorkflowStep should exclude archived tasks + countByStep, err := repo.CountTasksByWorkflowStep(ctx, "step-1") + if err != nil { + t.Fatalf("failed to count tasks by step: %v", err) + } + if countByStep != 1 { + t.Errorf("expected count 1, got %d", countByStep) + } +} + +func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Active Task"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Archived Task"}) + _ = repo.ArchiveTask(ctx, "task-2") + + // Without includeArchived: should return only active task + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, false) + if err != nil { + t.Fatalf("failed to list tasks: %v", err) + } + if total != 1 { + t.Errorf("expected 1 total without archived, got %d", total) + } + if len(tasks) != 1 || tasks[0].ID != "task-1" { + t.Errorf("expected only task-1, got %v", tasks) + } + + // With includeArchived: should return both tasks + tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10, true) + if err != nil { + t.Fatalf("failed to list tasks with archived: %v", err) + } + if total != 2 { + t.Errorf("expected 2 total with archived, got %d", total) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks, got %d", len(tasks)) + } + + // Search with includeArchived=false should filter archived + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, false) + if err != nil { + t.Fatalf("failed to search tasks: %v", err) + } + if total != 1 { + t.Errorf("expected 1 search result without archived, got %d", total) + } + + // Search with includeArchived=true should include archived + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "Task", 1, 10, true) + if err != nil { + t.Fatalf("failed to search tasks with archived: %v", err) + } + if total != 2 { + t.Errorf("expected 2 search results with archived, got %d", total) + } +} + +func TestSQLiteRepository_ListTasksForAutoArchive(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create the workflow_steps table (normally created by workflow repo) + _, err := repo.DB().Exec(` + CREATE TABLE IF NOT EXISTS workflow_steps ( + id TEXT PRIMARY KEY, + workflow_id TEXT NOT NULL, + name TEXT NOT NULL, + position INTEGER NOT NULL, + color TEXT, + prompt TEXT, + events TEXT, + allow_manual_move INTEGER DEFAULT 1, + is_start_step INTEGER DEFAULT 0, + auto_archive_after_hours INTEGER DEFAULT 0, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ) + `) + if err != nil { + t.Fatalf("failed to create workflow_steps table: %v", err) + } + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + workflow := &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Test Workflow"} + _ = repo.CreateWorkflow(ctx, workflow) + + now := time.Now().UTC() + + // Create workflow step with auto-archive after 1 hour + _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-done", "wf-1", "Done", 2, 1, now, now) + if err != nil { + t.Fatalf("failed to create workflow step: %v", err) + } + + // Create workflow step without auto-archive + _, err = repo.DB().Exec(`INSERT INTO workflow_steps (id, workflow_id, name, position, auto_archive_after_hours, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?)`, "step-todo", "wf-1", "Todo", 1, 0, now, now) + if err != nil { + t.Fatalf("failed to create workflow step: %v", err) + } + + oldTime := now.Add(-2 * time.Hour) + + // Task in auto-archive step, updated 2 hours ago — should be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-old", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Old Task", + }) + // Backdate updated_at (CreateTask sets it to now) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-old") + + // Task in auto-archive step, updated just now — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-recent", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Recent Task", + }) + + // Task in non-auto-archive step — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-todo", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-todo", Title: "Todo Task", + }) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-todo") + + // Already archived task — should NOT be eligible + _ = repo.CreateTask(ctx, &models.Task{ + ID: "task-archived", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-done", Title: "Already Archived", + }) + _, _ = repo.DB().Exec(`UPDATE tasks SET updated_at = ? WHERE id = ?`, oldTime, "task-archived") + _ = repo.ArchiveTask(ctx, "task-archived") + + // List candidates + candidates, err := repo.ListTasksForAutoArchive(ctx) + if err != nil { + t.Fatalf("failed to list auto-archive candidates: %v", err) + } + if len(candidates) != 1 { + t.Fatalf("expected 1 candidate, got %d", len(candidates)) + } + if candidates[0].ID != "task-old" { + t.Errorf("expected task-old, got %s", candidates[0].ID) + } +} diff --git a/apps/backend/internal/task/service/auto_archive.go b/apps/backend/internal/task/service/auto_archive.go new file mode 100644 index 0000000000..d675037934 --- /dev/null +++ b/apps/backend/internal/task/service/auto_archive.go @@ -0,0 +1,50 @@ +package service + +import ( + "context" + "time" + + "go.uber.org/zap" +) + +// StartAutoArchiveLoop starts a background goroutine that periodically archives tasks +// in workflow steps with auto_archive_after_hours > 0. +func (s *Service) StartAutoArchiveLoop(ctx context.Context) { + ticker := time.NewTicker(5 * time.Minute) + go func() { + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + s.runAutoArchive(ctx) + } + } + }() + s.logger.Info("auto-archive loop started (every 5 minutes)") +} + +func (s *Service) runAutoArchive(ctx context.Context) { + tasks, err := s.repo.ListTasksForAutoArchive(ctx) + if err != nil { + s.logger.Error("auto-archive: failed to list candidates", zap.Error(err)) + return + } + if len(tasks) == 0 { + return + } + + s.logger.Info("auto-archive: found candidates", zap.Int("count", len(tasks))) + for _, task := range tasks { + if err := s.ArchiveTask(ctx, task.ID); err != nil { + s.logger.Warn("auto-archive: failed to archive task", + zap.String("task_id", task.ID), + zap.Error(err)) + } else { + s.logger.Info("auto-archive: archived task", + zap.String("task_id", task.ID), + zap.String("title", task.Title)) + } + } +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 2421fad04b..fc4b8944c5 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -580,6 +580,108 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ return task, nil } +// ArchiveTask archives a task by setting its archived_at timestamp. +// The task remains in the DB but is excluded from active board views. +// Active agent sessions are stopped and worktrees cleaned up in background. +func (s *Service) ArchiveTask(ctx context.Context, id string) error { + start := time.Now() + + // 1. Get task and verify it exists + task, err := s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + if task.ArchivedAt != nil { + return fmt.Errorf("task is already archived: %s", id) + } + + // 2. Gather data needed for cleanup BEFORE archive + var activeSessionIDs []string + if s.executionStopper != nil { + activeSessions, err := s.repo.ListActiveTaskSessionsByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list active sessions for archive", + zap.String("task_id", id), + zap.Error(err)) + } + for _, sess := range activeSessions { + activeSessionIDs = append(activeSessionIDs, sess.ID) + } + } + + sessions, err := s.repo.ListTaskSessions(ctx, id) + if err != nil { + s.logger.Warn("failed to list task sessions for archive", + zap.String("task_id", id), + zap.Error(err)) + } + + var worktrees []*worktree.Worktree + if s.worktreeCleanup != nil { + if provider, ok := s.worktreeCleanup.(WorktreeProvider); ok { + worktrees, err = provider.GetAllByTaskID(ctx, id) + if err != nil { + s.logger.Warn("failed to list worktrees for archive", + zap.String("task_id", id), + zap.Error(err)) + } + } + } + + // 3. Set archived_at in DB + if err := s.repo.ArchiveTask(ctx, id); err != nil { + return err + } + + // 4. Re-read task for updated archived_at field + task, err = s.repo.GetTask(ctx, id) + if err != nil { + return err + } + + // 5. Publish task.updated event so frontend removes from board + s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + s.logger.Info("task archived", + zap.String("task_id", id), + zap.Duration("duration", time.Since(start))) + + // 6. Background: Stop agents and cleanup worktrees + if len(activeSessionIDs) > 0 || s.worktreeCleanup != nil || len(sessions) > 0 { + go func() { + cleanupStart := time.Now() + cleanupCtx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + if s.executionStopper != nil && len(activeSessionIDs) > 0 { + for _, sessionID := range activeSessionIDs { + if err := s.executionStopper.StopSession(cleanupCtx, sessionID, "task archived", true); err != nil { + s.logger.Warn("failed to stop session on task archive", + zap.String("task_id", id), + zap.String("session_id", sessionID), + zap.Error(err)) + } + } + } + + cleanupErrors := s.performTaskCleanup(cleanupCtx, id, sessions, worktrees) + + if len(cleanupErrors) > 0 { + s.logger.Warn("task archive cleanup completed with errors", + zap.String("task_id", id), + zap.Int("error_count", len(cleanupErrors)), + zap.Duration("duration", time.Since(cleanupStart))) + } else { + s.logger.Info("task archive cleanup completed", + zap.String("task_id", id), + zap.Duration("duration", time.Since(cleanupStart))) + } + }() + } + + return nil +} + // DeleteTask deletes a task and publishes a task.deleted event. // For fast UI response, the DB delete and event publish happen synchronously, // while agent stopping and worktree cleanup happen asynchronously. @@ -749,8 +851,8 @@ func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.T // ListTasksByWorkspace returns paginated tasks for a workspace with task repositories loaded. // If query is non-empty, filters by task title, description, repository name, or repository path. -func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { - tasks, total, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, query, page, pageSize) +func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int, includeArchived bool) ([]*models.Task, int, error) { + tasks, total, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, query, page, pageSize, includeArchived) if err != nil { return nil, 0, err } @@ -1791,6 +1893,10 @@ func (s *Service) publishTaskEvent(ctx context.Context, eventType string, task * } } + if task.ArchivedAt != nil { + data["archived_at"] = task.ArchivedAt.Format(time.RFC3339) + } + if len(task.Repositories) > 0 { data["repository_id"] = task.Repositories[0].RepositoryID } diff --git a/apps/backend/internal/workflow/controller/controller.go b/apps/backend/internal/workflow/controller/controller.go index ff2b0e5e43..d3abbb4afb 100644 --- a/apps/backend/internal/workflow/controller/controller.go +++ b/apps/backend/internal/workflow/controller/controller.go @@ -118,14 +118,15 @@ func (c *Controller) CreateStep(ctx context.Context, req CreateStepRequest) (*Ge // UpdateStepRequest is the request for updating a workflow step. type UpdateStepRequest struct { - ID string `json:"id"` - Name *string `json:"name,omitempty"` - Position *int `json:"position,omitempty"` - Color *string `json:"color,omitempty"` - Prompt *string `json:"prompt,omitempty"` - Events *models.StepEvents `json:"events,omitempty"` - AllowManualMove *bool `json:"allow_manual_move,omitempty"` - IsStartStep *bool `json:"is_start_step,omitempty"` + ID string `json:"id"` + Name *string `json:"name,omitempty"` + Position *int `json:"position,omitempty"` + Color *string `json:"color,omitempty"` + Prompt *string `json:"prompt,omitempty"` + Events *models.StepEvents `json:"events,omitempty"` + AllowManualMove *bool `json:"allow_manual_move,omitempty"` + IsStartStep *bool `json:"is_start_step,omitempty"` + AutoArchiveAfterHours *int `json:"auto_archive_after_hours,omitempty"` } // UpdateStep updates an existing workflow step. @@ -155,6 +156,9 @@ func (c *Controller) UpdateStep(ctx context.Context, req UpdateStepRequest) (*Ge if req.IsStartStep != nil { step.IsStartStep = *req.IsStartStep } + if req.AutoArchiveAfterHours != nil { + step.AutoArchiveAfterHours = *req.AutoArchiveAfterHours + } if err := c.svc.UpdateStep(ctx, step); err != nil { return nil, err } diff --git a/apps/backend/internal/workflow/models/models.go b/apps/backend/internal/workflow/models/models.go index 61d0f0a277..187a826a58 100644 --- a/apps/backend/internal/workflow/models/models.go +++ b/apps/backend/internal/workflow/models/models.go @@ -79,29 +79,31 @@ type WorkflowTemplate struct { // StepDefinition represents a step in a workflow template (stored as JSON in WorkflowTemplate) type StepDefinition struct { - ID string `json:"id"` - Name string `json:"name"` - Position int `json:"position"` - Color string `json:"color"` - Prompt string `json:"prompt,omitempty"` - Events StepEvents `json:"events"` - AllowManualMove bool `json:"allow_manual_move"` - IsStartStep bool `json:"is_start_step"` + ID string `json:"id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events StepEvents `json:"events"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep bool `json:"is_start_step"` + AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` } // WorkflowStep represents a step in a workflow type WorkflowStep struct { - ID string `json:"id"` - WorkflowID string `json:"workflow_id"` - Name string `json:"name"` - Position int `json:"position"` - Color string `json:"color"` - Prompt string `json:"prompt,omitempty"` - Events StepEvents `json:"events"` - AllowManualMove bool `json:"allow_manual_move"` - IsStartStep bool `json:"is_start_step"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + WorkflowID string `json:"workflow_id"` + Name string `json:"name"` + Position int `json:"position"` + Color string `json:"color"` + Prompt string `json:"prompt,omitempty"` + Events StepEvents `json:"events"` + AllowManualMove bool `json:"allow_manual_move"` + IsStartStep bool `json:"is_start_step"` + AutoArchiveAfterHours int `json:"auto_archive_after_hours,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // HasOnEnterAction checks if the step has a specific on_enter action type. diff --git a/apps/backend/internal/workflow/repository/sqlite.go b/apps/backend/internal/workflow/repository/sqlite.go index 3804b30c35..269a6912e6 100644 --- a/apps/backend/internal/workflow/repository/sqlite.go +++ b/apps/backend/internal/workflow/repository/sqlite.go @@ -82,6 +82,11 @@ func (r *Repository) initSchema() error { return fmt.Errorf("failed to add is_start_step column: %w", err) } + // Add auto_archive_after_hours column if not present + if err := r.addAutoArchiveColumn(); err != nil { + return fmt.Errorf("failed to add auto_archive_after_hours column: %w", err) + } + // Create session_step_history table historySchema := ` CREATE TABLE IF NOT EXISTS session_step_history ( @@ -297,6 +302,40 @@ func (r *Repository) addIsStartStepColumn() error { return err } +// addAutoArchiveColumn adds the auto_archive_after_hours column to workflow_steps if it doesn't exist. +func (r *Repository) addAutoArchiveColumn() error { + rows, err := r.db.Query("PRAGMA table_info(workflow_steps)") + if err != nil { + return err + } + defer func() { _ = rows.Close() }() + + var hasColumn bool + for rows.Next() { + var cid int + var name, colType string + var notNull int + var dfltValue sql.NullString + var pk int + if err := rows.Scan(&cid, &name, &colType, ¬Null, &dfltValue, &pk); err != nil { + return err + } + if name == "auto_archive_after_hours" { + hasColumn = true + break + } + } + if err := rows.Err(); err != nil { + return err + } + if hasColumn { + return nil + } + + _, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN auto_archive_after_hours INTEGER DEFAULT 0") + return err +} + // convertOldStepToEvents converts old boolean/pointer fields to new event-driven model. func convertOldStepToEvents(autoStart, planMode, requireApproval bool, onCompleteStepID, onApprovalStepID sql.NullString) models.StepEvents { events := models.StepEvents{} @@ -871,11 +910,11 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep) _, err = r.db.ExecContext(ctx, ` INSERT INTO workflow_steps ( id, workflow_id, name, position, color, - prompt, events, allow_manual_move, is_start_step, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, step.ID, step.WorkflowID, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), sqlite.BoolToInt(step.AllowManualMove), - sqlite.BoolToInt(step.IsStartStep), step.CreatedAt, step.UpdatedAt) + sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt) return err } @@ -886,10 +925,11 @@ func (r *Repository) scanStep(row interface { }) (*models.WorkflowStep, error) { step := &models.WorkflowStep{} var allowManualMove, isStartStep int + var autoArchiveAfterHours sql.NullInt64 var color, prompt, eventsJSON sql.NullString err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color, - &prompt, &eventsJSON, &allowManualMove, &isStartStep, &step.CreatedAt, &step.UpdatedAt) + &prompt, &eventsJSON, &allowManualMove, &isStartStep, &autoArchiveAfterHours, &step.CreatedAt, &step.UpdatedAt) if err != nil { return nil, err @@ -897,6 +937,9 @@ func (r *Repository) scanStep(row interface { step.AllowManualMove = allowManualMove == 1 step.IsStartStep = isStartStep == 1 + if autoArchiveAfterHours.Valid { + step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64) + } if color.Valid { step.Color = color.String } @@ -912,7 +955,7 @@ func (r *Repository) scanStep(row interface { return step, nil } -const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, created_at, updated_at` +const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at` // GetStep retrieves a workflow step by ID. func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) { @@ -944,11 +987,11 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep) UPDATE workflow_steps SET name = ?, position = ?, color = ?, prompt = ?, events = ?, - allow_manual_move = ?, is_start_step = ?, updated_at = ? + allow_manual_move = ?, is_start_step = ?, auto_archive_after_hours = ?, updated_at = ? WHERE id = ? `, step.Name, step.Position, step.Color, step.Prompt, string(eventsJSON), - sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.UpdatedAt, step.ID) + sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID) if err != nil { return err } diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 86c759a96f..dbee89b92b 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -55,6 +55,7 @@ const ( ActionTaskDelete = "task.delete" ActionTaskMove = "task.move" ActionTaskState = "task.state" + ActionTaskArchive = "task.archive" ActionTaskPlanCreate = "task.plan.create" ActionTaskPlanGet = "task.plan.get" ActionTaskPlanUpdate = "task.plan.update" diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index da7a3c5bf0..b7704283fc 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -293,6 +293,7 @@ type BackendWorkflowStep = { events?: StepEvents; allow_manual_move?: boolean; is_start_step?: boolean; + auto_archive_after_hours?: number; created_at: string; updated_at: string; }; @@ -307,6 +308,7 @@ const transformWorkflowStep = (step: BackendWorkflowStep): WorkflowStep => ({ events: step.events, allow_manual_move: step.allow_manual_move, is_start_step: step.is_start_step, + auto_archive_after_hours: step.auto_archive_after_hours, created_at: step.created_at, updated_at: step.updated_at, }); @@ -351,7 +353,7 @@ export async function createWorkflowStepAction(payload: { export async function updateWorkflowStepAction( stepId: string, - payload: Partial> + payload: Partial> ): Promise { const body: Record = {}; if (payload.name !== undefined) body.name = payload.name; @@ -361,6 +363,7 @@ export async function updateWorkflowStepAction( if (payload.events !== undefined) body.events = payload.events; if (payload.allow_manual_move !== undefined) body.allow_manual_move = payload.allow_manual_move; if (payload.is_start_step !== undefined) body.is_start_step = payload.is_start_step; + if (payload.auto_archive_after_hours !== undefined) body.auto_archive_after_hours = payload.auto_archive_after_hours; const response = await fetchJson(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, { method: 'PUT', body: JSON.stringify(body), diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx index 47718ea89f..dba7e2623a 100644 --- a/apps/web/app/tasks/columns.tsx +++ b/apps/web/app/tasks/columns.tsx @@ -3,10 +3,10 @@ import type { ColumnDef } from "@tanstack/react-table" import type { Task, Workflow, WorkflowStep, Repository } from "@/lib/types/http" import Link from "next/link" -import { IconTrash, IconLoader } from "@tabler/icons-react" +import { IconTrash, IconLoader, IconArchive } from "@tabler/icons-react" import { Button } from "@kandev/ui/button" import { Badge } from "@kandev/ui/badge" -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip" +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip" import { formatDistanceToNow } from "date-fns" type TaskWithResolution = Task & { @@ -19,6 +19,7 @@ interface ColumnsConfig { workflows: Workflow[] steps: WorkflowStep[] repositories: Repository[] + onArchive: (taskId: string) => void onDelete: (taskId: string) => void deletingTaskId: string | null } @@ -27,85 +28,74 @@ export function getColumns({ workflows, steps, repositories, + onArchive, onDelete, deletingTaskId, }: ColumnsConfig): ColumnDef[] { const workflowMap = new Map(workflows.map((w) => [w.id, w.name])) const stepMap = new Map(steps.map((s) => [s.id, s.name])) - const repoMap = new Map(repositories.map((r) => [r.id, { name: r.name, path: r.local_path }])) + const repoMap = new Map(repositories.map((r) => [r.id, r.name])) return [ { accessorKey: "title", - header: "Title", + header: "Task", cell: ({ row }) => { const task = row.original const sessionId = task.primary_session_id - if (sessionId) { - return ( - - {task.title} - - ) - } - return {task.title} + const isArchived = !!task.archived_at + const repoName = task.repositories?.[0] + ? repoMap.get(task.repositories[0].repository_id) + : undefined + + return ( +
+
+ {sessionId ? ( + + {task.title} + + ) : ( + {task.title} + )} + {isArchived && ( + + Archived + + )} +
+ {repoName && ( + {repoName} + )} +
+ ) }, }, { accessorKey: "workflow_id", header: "Workflow", cell: ({ row }) => { - const workflowName = workflowMap.get(row.original.workflow_id) - return workflowName || "-" - }, - }, - { - accessorKey: "workflow_step_id", - header: "Column", - cell: ({ row }) => { - const stepName = stepMap.get(row.original.workflow_step_id) - return stepName || "-" - }, - }, - { - accessorKey: "repositories", - header: "Repository", - cell: ({ row }) => { - const taskRepos = row.original.repositories || [] - if (taskRepos.length === 0) return "-" - const repoInfos = taskRepos - .map((tr) => repoMap.get(tr.repository_id)) - .filter((info): info is { name: string; path: string } => info !== undefined) - if (repoInfos.length === 0) return "-" - + const name = workflowMap.get(row.original.workflow_id) return ( - -
- {repoInfos.map((info, idx) => ( - - - {info.name} - - -

{info.path || 'No path'}

-
-
- ))} -
-
+ + {name || "-"} + ) }, }, { - accessorKey: "state", - header: "State", + accessorKey: "workflow_step_id", + header: "Step", cell: ({ row }) => { - const state = row.original.state - const variant = state === "COMPLETED" ? "default" : state === "IN_PROGRESS" ? "secondary" : "outline" - return {state} + const stepName = stepMap.get(row.original.workflow_step_id) + return ( + + {stepName || "-"} + + ) }, }, { @@ -113,31 +103,62 @@ export function getColumns({ header: "Updated", cell: ({ row }) => { const date = new Date(row.original.updated_at) - return formatDistanceToNow(date, { addSuffix: true }) + return ( + + {formatDistanceToNow(date, { addSuffix: true })} + + ) }, }, { id: "actions", - header: "Actions", + header: "", cell: ({ row }) => { - const isDeleting = deletingTaskId === row.original.id + const task = row.original + const isDeleting = deletingTaskId === task.id + const isArchived = !!task.archived_at return ( - + + Archive + )} - + + + + + Delete + + ) }, }, diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index fd93dc41fd..5ff4893f8f 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -5,9 +5,11 @@ import { useRouter } from "next/navigation" import type { PaginationState } from "@tanstack/react-table" import { DataTable } from "@/components/ui/data-table" import { getColumns } from "./columns" -import { deleteTask, listTasksByWorkspace } from "@/lib/api" +import { archiveTask, deleteTask, listTasksByWorkspace } from "@/lib/api" import { TaskCreateDialog } from "@/components/task-create-dialog" import { KanbanHeader } from "@/components/kanban/kanban-header" +import { Checkbox } from "@kandev/ui/checkbox" +import { Label } from "@kandev/ui/label" import type { Task, Workspace, Workflow, WorkflowStep, Repository } from "@/lib/types/http" import { useToast } from "@/components/toast-provider" import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings" @@ -49,9 +51,10 @@ export function TasksPageClient({ const [createDialogOpen, setCreateDialogOpen] = useState(false) const [deletingTaskId, setDeletingTaskId] = useState(null) - // Search state + // Search and filter state const [searchQuery, setSearchQuery] = useState('') const debouncedQuery = useDebounce(searchQuery, 300) + const [showArchived, setShowArchived] = useState(false) const [pagination, setPagination] = useState({ pageIndex: 0, @@ -71,6 +74,7 @@ export function TasksPageClient({ page: pagination.pageIndex + 1, pageSize: pagination.pageSize, query: debouncedQuery, + includeArchived: showArchived, }) setTasks(result.tasks) @@ -84,7 +88,7 @@ export function TasksPageClient({ } finally { setIsLoading(false) } - }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, toast]) + }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, showArchived, toast]) // Reset to page 1 when search query changes useEffect(() => { @@ -96,12 +100,32 @@ export function TasksPageClient({ if (activeWorkspaceId) { fetchTasks() } - }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, fetchTasks]) + }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, showArchived, fetchTasks]) const handleCreateTask = useCallback(() => { setCreateDialogOpen(true) }, []) + const handleArchive = useCallback( + async (taskId: string) => { + try { + await archiveTask(taskId) + toast({ + title: "Task archived", + description: "The task has been archived successfully.", + }) + fetchTasks() + } catch (err) { + toast({ + title: "Failed to archive task", + description: err instanceof Error ? err.message : "Unknown error", + variant: "error", + }) + } + }, + [fetchTasks, toast] + ) + const handleDelete = useCallback( async (taskId: string) => { setDeletingTaskId(taskId) @@ -131,10 +155,11 @@ export function TasksPageClient({ workflows, steps, repositories, + onArchive: handleArchive, onDelete: handleDelete, deletingTaskId, }), - [workflows, steps, repositories, handleDelete, deletingTaskId] + [workflows, steps, repositories, handleArchive, handleDelete, deletingTaskId] ) const handleRowClick = useCallback( @@ -166,23 +191,37 @@ export function TasksPageClient({ onSearchChange={setSearchQuery} isSearchLoading={isLoading && !!debouncedQuery} /> -
-
-

All Tasks

-

- {total} task{total !== 1 ? 's' : ''} found -

-
+
+
+
+
+

All Tasks

+

+ {total} task{total !== 1 ? 's' : ''} found +

+
+
+ setShowArchived(checked === true)} + /> + +
+
- + +
{activeWorkspaceId && defaultWorkflow && defaultStep && ( diff --git a/apps/web/components/settings/workflow-pipeline-editor.tsx b/apps/web/components/settings/workflow-pipeline-editor.tsx index e0ff91ee98..4aa2273e95 100644 --- a/apps/web/components/settings/workflow-pipeline-editor.tsx +++ b/apps/web/components/settings/workflow-pipeline-editor.tsx @@ -482,6 +482,39 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
+ {/* Auto-archive setting */} +
+ 0} + onCheckedChange={(checked) => { + if (readOnly) return; + onUpdate({ auto_archive_after_hours: checked ? 24 : 0 }); + }} + disabled={readOnly} + /> + + + {(step.auto_archive_after_hours ?? 0) > 0 && ( + <> + after + { + if (readOnly) return; + const val = parseInt(e.target.value, 10); + onUpdate({ auto_archive_after_hours: isNaN(val) || val < 1 ? 1 : val }); + }} + disabled={readOnly} + /> + hours + + )} +
{/* Transitions section */} diff --git a/apps/web/components/task/changes-panel.tsx b/apps/web/components/task/changes-panel.tsx index 79e5e48a56..625e72f49e 100644 --- a/apps/web/components/task/changes-panel.tsx +++ b/apps/web/components/task/changes-panel.tsx @@ -38,6 +38,7 @@ import { hashDiff, normalizeDiffContent } from '@/components/review/types'; import { getWebSocketClient } from '@/lib/ws/connection'; import type { FileInfo } from '@/lib/state/store'; import { useToast } from '@/components/toast-provider'; +import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context'; import { usePanelActions } from '@/hooks/use-panel-actions'; import { FileStatusIcon } from './file-status-icon'; @@ -55,6 +56,7 @@ type ChangesPanelProps = { }; const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelProps) { + const isArchived = useIsTaskArchived(); const [showDiscardDialog, setShowDiscardDialog] = useState(false); const [fileToDiscard, setFileToDiscard] = useState(null); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); @@ -241,6 +243,8 @@ const ChangesPanel = memo(function ChangesPanel({ onSelectDiff }: ChangesPanelPr panelOpenFile(path); }, [activeSessionId, panelOpenFile]); + if (isArchived) return ; + return ( diff --git a/apps/web/components/task/files-panel.tsx b/apps/web/components/task/files-panel.tsx index 3c4d56ccc0..4b667b0fc7 100644 --- a/apps/web/components/task/files-panel.tsx +++ b/apps/web/components/task/files-panel.tsx @@ -8,6 +8,7 @@ import { getWebSocketClient } from '@/lib/ws/connection'; import { deleteFile } from '@/lib/ws/workspace-files'; import { FileBrowser } from '@/components/task/file-browser'; import type { OpenFileTab } from '@/lib/types/backend'; +import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context'; type FilesPanelProps = { onOpenFile: (file: OpenFileTab) => void; @@ -16,6 +17,7 @@ type FilesPanelProps = { const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { const [isSearchOpen, setIsSearchOpen] = useState(false); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); + const isArchived = useIsTaskArchived(); const { toast } = useToast(); const handleDeleteFile = useCallback(async (path: string) => { @@ -40,6 +42,8 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { } }, [activeSessionId, toast]); + if (isArchived) return ; + return ( diff --git a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx index 2c7ea09e2e..c9e7f5958a 100644 --- a/apps/web/components/task/mobile/session-task-switcher-sheet.tsx +++ b/apps/web/components/task/mobile/session-task-switcher-sheet.tsx @@ -14,9 +14,10 @@ import { WorkspaceSwitcher } from '../workspace-switcher'; import { TaskCreateDialog } from '@/components/task-create-dialog'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { linkToSession } from '@/lib/links'; -import { listTaskSessions, fetchWorkflowSnapshot, listWorkflows } from '@/lib/api'; +import { fetchWorkflowSnapshot, listWorkflows } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; import { useTaskActions } from '@/hooks/use-task-actions'; +import { useTaskRemoval } from '@/hooks/use-task-removal'; import type { TaskState, Workspace, Repository, TaskSession, Task, WorkflowSnapshot } from '@/lib/types/http'; import type { KanbanState } from '@/lib/state/slices'; @@ -84,8 +85,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); - const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); - const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); const store = useAppStoreApi(); const selectedTaskId = useMemo(() => { @@ -96,7 +95,8 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ }, [activeSessionId, activeTaskId, sessionsById]); const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false); - const { deleteTaskById } = useTaskActions(); + const { deleteTaskById, archiveTaskById } = useTaskActions(); + const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store }); // Get session info for a task (diff stats) const getSessionInfoForTask = useCallback( @@ -148,31 +148,6 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ window.history.replaceState({}, '', linkToSession(sessionId)); }, []); - const loadTaskSessionsForTask = useCallback( - async (taskId: string) => { - const state = store.getState(); - if (state.taskSessionsByTask.loadedByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - if (state.taskSessionsByTask.loadingByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - setTaskSessionsLoading(taskId, true); - try { - const response = await listTaskSessions(taskId, { cache: 'no-store' }); - setTaskSessionsForTask(taskId, response.sessions ?? []); - return response.sessions ?? []; - } catch (error) { - console.error('Failed to load task sessions:', error); - setTaskSessionsForTask(taskId, []); - return []; - } finally { - setTaskSessionsLoading(taskId, false); - } - }, - [setTaskSessionsForTask, setTaskSessionsLoading, store] - ); - const handleSelectTask = useCallback( (taskId: string) => { const kanbanTasks = store.getState().kanban.tasks; @@ -201,51 +176,29 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ [loadTaskSessionsForTask, setActiveSession, setActiveTask, updateUrl, store, onOpenChange] ); + const handleArchiveTask = useCallback( + async (taskId: string) => { + try { + await archiveTaskById(taskId); + await removeTaskFromBoard(taskId); + } catch (error) { + console.error('Failed to archive task:', error); + } + }, + [archiveTaskById, removeTaskFromBoard] + ); + const handleDeleteTask = useCallback( async (taskId: string) => { setDeletingTaskId(taskId); try { await deleteTaskById(taskId); - - const currentTasks = store.getState().kanban.tasks; - const remainingTasks = currentTasks.filter( - (item: KanbanState['tasks'][number]) => item.id !== taskId - ); - - store.setState((state) => ({ - ...state, - kanban: { - ...state.kanban, - tasks: remainingTasks, - }, - })); - - const currentActiveTaskId = store.getState().tasks.activeTaskId; - if (currentActiveTaskId === taskId) { - if (remainingTasks.length > 0) { - const nextTask = remainingTasks[0]; - if (nextTask.primarySessionId) { - setActiveSession(nextTask.id, nextTask.primarySessionId); - window.history.replaceState({}, '', linkToSession(nextTask.primarySessionId)); - } else { - const sessions = await loadTaskSessionsForTask(nextTask.id); - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(nextTask.id, sessionId); - window.history.replaceState({}, '', linkToSession(sessionId)); - } else { - setActiveTask(nextTask.id); - } - } - } else { - window.location.href = '/'; - } - } + await removeTaskFromBoard(taskId); } finally { setDeletingTaskId(null); } }, - [deleteTaskById, store, setActiveSession, setActiveTask, loadTaskSessionsForTask] + [deleteTaskById, removeTaskFromBoard] ); const handleWorkspaceChange = useCallback( @@ -384,6 +337,7 @@ export const SessionTaskSwitcherSheet = memo(function SessionTaskSwitcherSheet({ activeTaskId={activeTaskId} selectedTaskId={selectedTaskId} onSelectTask={handleSelectTask} + onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} deletingTaskId={deletingTaskId} isLoading={kanbanIsLoading} diff --git a/apps/web/components/task/task-archived-context.tsx b/apps/web/components/task/task-archived-context.tsx new file mode 100644 index 0000000000..08339dc081 --- /dev/null +++ b/apps/web/components/task/task-archived-context.tsx @@ -0,0 +1,36 @@ +'use client'; + +import { createContext, useContext, memo } from 'react'; +import { PanelRoot, PanelBody } from './panel-primitives'; + +type TaskArchivedState = { + isArchived: boolean; + archivedTaskId?: string; + archivedTaskTitle?: string; + archivedTaskRepositoryPath?: string; + archivedTaskUpdatedAt?: string; +}; + +const TaskArchivedContext = createContext({ isArchived: false }); + +export const TaskArchivedProvider = TaskArchivedContext.Provider; + +export function useIsTaskArchived() { + return useContext(TaskArchivedContext).isArchived; +} + +export function useArchivedTaskState() { + return useContext(TaskArchivedContext); +} + +export const ArchivedPanelPlaceholder = memo(function ArchivedPanelPlaceholder({ message = 'Workspace not available — this task is archived' }: { message?: string }) { + return ( + + +
+ {message} +
+
+
+ ); +}); diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx index 8e5615a2ea..731840483b 100644 --- a/apps/web/components/task/task-changes-panel.tsx +++ b/apps/web/components/task/task-changes-panel.tsx @@ -36,6 +36,7 @@ import { hashDiff, normalizeDiffContent } from '@/components/review/types'; import type { DiffComment } from '@/lib/diff/types'; import { usePanelActions } from '@/hooks/use-panel-actions'; import type { SelectedDiff } from './task-layout'; +import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context'; type TaskChangesPanelProps = { selectedDiff: SelectedDiff | null; @@ -49,6 +50,7 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ onClearSelected, onOpenFile: onOpenFileProp, }: TaskChangesPanelProps) { + const isArchived = useIsTaskArchived(); const { openFile: panelOpenFile } = usePanelActions(); const handleOpenFile = onOpenFileProp ?? panelOpenFile; @@ -297,6 +299,8 @@ const TaskChangesPanel = memo(function TaskChangesPanel({ const totalCount = allFiles.length; const progressPercent = totalCount > 0 ? (reviewedCount / totalCount) * 100 : 0; + if (isArchived) return ; + return ( {/* Top bar */} diff --git a/apps/web/components/task/task-chat-panel.tsx b/apps/web/components/task/task-chat-panel.tsx index e71c40291e..e7cc3c8aae 100644 --- a/apps/web/components/task/task-chat-panel.tsx +++ b/apps/web/components/task/task-chat-panel.tsx @@ -28,6 +28,7 @@ import { } from '@/lib/state/slices/diff-comments'; import { getFileName } from '@/lib/utils/file-path'; import type { ContextItem } from '@/lib/types/context'; +import { useIsTaskArchived } from './task-archived-context'; import type { DiffComment } from '@/lib/diff/types'; import type { DocumentComment } from '@/lib/state/slices/ui/types'; @@ -78,6 +79,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({ onOpenFileAtLine, isPanelFocused, }: TaskChatPanelProps) { + const isArchived = useIsTaskArchived(); const [isSending, setIsSending] = useState(false); const lastAgentMessageCountRef = useRef(0); const chatInputRef = useRef(null); @@ -479,6 +481,11 @@ export const TaskChatPanel = memo(function TaskChatPanel({
{/* Sticky input at bottom */} + {isArchived ? ( +
+ This task is archived and read-only. +
+ ) : (
+ )}
); }); diff --git a/apps/web/components/task/task-item-menu.tsx b/apps/web/components/task/task-item-menu.tsx index 1e5ff55c4b..1acc87cfa8 100644 --- a/apps/web/components/task/task-item-menu.tsx +++ b/apps/web/components/task/task-item-menu.tsx @@ -1,6 +1,6 @@ 'use client'; -import { IconDots, IconTrash, IconCopy, IconEye, IconPencil, IconLoader } from '@tabler/icons-react'; +import { IconDots, IconTrash, IconCopy, IconEye, IconPencil, IconLoader, IconArchive } from '@tabler/icons-react'; import { DropdownMenu, DropdownMenuContent, @@ -16,6 +16,7 @@ type TaskItemMenuProps = { onRename?: () => void; onDuplicate?: () => void; onReview?: () => void; + onArchive?: () => void; onDelete?: () => void; isDeleting?: boolean; }; @@ -26,6 +27,7 @@ export function TaskItemMenu({ onRename, onDuplicate, onReview, + onArchive, onDelete, isDeleting, }: TaskItemMenuProps) { @@ -74,6 +76,15 @@ export function TaskItemMenu({ Review + { + e.stopPropagation(); + onArchive?.(); + }} + > + + Archive + void; diffStats?: DiffStats; @@ -23,6 +24,7 @@ type TaskItemProps = { onRename?: () => void; onDuplicate?: () => void; onReview?: () => void; + onArchive?: () => void; onDelete?: () => void; isDeleting?: boolean; }; @@ -49,6 +51,7 @@ export const TaskItem = memo(function TaskItem({ description, stepName, state, + isArchived, isSelected = false, onClick, diffStats, @@ -56,6 +59,7 @@ export const TaskItem = memo(function TaskItem({ onRename, onDuplicate, onReview, + onArchive, onDelete, isDeleting, }: TaskItemProps) { @@ -137,11 +141,15 @@ export const TaskItem = memo(function TaskItem({ effectiveMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' )} > - {stepName && ( + {isArchived ? ( + + Archived + + ) : stepName ? ( {stepName} - )} + ) : null} {renderRightMeta()} @@ -162,6 +170,7 @@ export const TaskItem = memo(function TaskItem({ onRename={onRename} onDuplicate={onDuplicate} onReview={onReview} + onArchive={onArchive} onDelete={onDelete} isDeleting={isDeleting} /> diff --git a/apps/web/components/task/task-page-content.tsx b/apps/web/components/task/task-page-content.tsx index 3413331d0f..e1682886c4 100644 --- a/apps/web/components/task/task-page-content.tsx +++ b/apps/web/components/task/task-page-content.tsx @@ -18,6 +18,7 @@ import { fetchTask } from '@/lib/api'; import { useTasks } from '@/hooks/use-tasks'; import { useResponsiveBreakpoint } from '@/hooks/use-responsive-breakpoint'; import type { Layout } from 'react-resizable-panels'; +import { TaskArchivedProvider } from './task-archived-context'; type TaskPageContentProps = { task: Task | null; @@ -218,6 +219,14 @@ export function TaskPageContent({ ); + const archivedValue = useMemo(() => ({ + isArchived: !!task?.archived_at, + archivedTaskId: task?.archived_at ? task.id : undefined, + archivedTaskTitle: task?.archived_at ? task.title : undefined, + archivedTaskRepositoryPath: task?.archived_at ? repository?.local_path ?? undefined : undefined, + archivedTaskUpdatedAt: task?.archived_at ? task.updated_at : undefined, + }), [task, repository]); + if (!isMounted) { return
; } @@ -270,21 +279,24 @@ export function TaskPageContent({ workflowSteps={workflowSteps} currentStepId={task?.workflow_step_id ?? null} workflowId={task?.workflow_id ?? null} + isArchived={!!task?.archived_at} /> )} - + + +
); diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index 9a47ebdd53..350ce6fd3a 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -10,10 +10,11 @@ import { IconPlus } from '@tabler/icons-react'; import { TaskCreateDialog } from '@/components/task-create-dialog'; import { useAppStore, useAppStoreApi } from '@/components/state-provider'; import { linkToSession } from '@/lib/links'; -import { listTaskSessions } from '@/lib/api'; import { useAllWorkflowSnapshots } from '@/hooks/domains/kanban/use-all-workflow-snapshots'; import { useTaskActions } from '@/hooks/use-task-actions'; +import { useTaskRemoval } from '@/hooks/use-task-removal'; import { performLayoutSwitch } from '@/lib/state/dockview-store'; +import { useArchivedTaskState } from './task-archived-context'; // Extracted component to isolate dialog state from sidebar type NewTaskButtonProps = { @@ -61,7 +62,7 @@ type TaskSessionSidebarProps = { workflowId: string | null; }; -export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId, workflowId: _workflowId }: TaskSessionSidebarProps) { +export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId }: TaskSessionSidebarProps) { const activeTaskId = useAppStore((state) => state.tasks.activeTaskId); const activeSessionId = useAppStore((state) => state.tasks.activeSessionId); const sessionsById = useAppStore((state) => state.taskSessions.items); @@ -72,8 +73,6 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); const setActiveTask = useAppStore((state) => state.setActiveTask); const setActiveSession = useAppStore((state) => state.setActiveSession); - const setTaskSessionsForTask = useAppStore((state) => state.setTaskSessionsForTask); - const setTaskSessionsLoading = useAppStore((state) => state.setTaskSessionsLoading); const selectedTaskId = useMemo(() => { if (activeSessionId) { return sessionsById[activeSessionId]?.task_id ?? activeTaskId; @@ -107,9 +106,10 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId }, [snapshots]); const store = useAppStoreApi(); - // Task deletion state and handler - const { deleteTaskById } = useTaskActions(); + const { deleteTaskById, archiveTaskById } = useTaskActions(); + const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({ store, useLayoutSwitch: true }); const [deletingTaskId, setDeletingTaskId] = useState(null); + const archivedState = useArchivedTaskState(); // Helper to get session info for a task (diff stats and updatedAt) const getSessionInfoForTask = useCallback( @@ -146,20 +146,38 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId const tasksWithRepositories = useMemo(() => { const repositories = workspaceId ? repositoriesByWorkspace[workspaceId] ?? [] : []; const repositoryPathsById = new Map(repositories.map((repo: Repository) => [repo.id, repo.local_path])); - return allTasks.map((task: KanbanState['tasks'][number]) => { + const items = allTasks.map((task: KanbanState['tasks'][number]) => { const sessionInfo = getSessionInfoForTask(task.id); return { id: task.id, title: task.title, state: task.state as TaskState | undefined, description: task.description, - workflowStepId: task.workflowStepId, + workflowStepId: task.workflowStepId as string | undefined, repositoryPath: task.repositoryId ? repositoryPathsById.get(task.repositoryId) : undefined, diffStats: sessionInfo.diffStats, updatedAt: sessionInfo.updatedAt ?? task.updatedAt, + isArchived: false as boolean, }; }); - }, [repositoriesByWorkspace, allTasks, workspaceId, getSessionInfoForTask]); + + // If the active task is archived (not in kanban), inject it into the list + if (archivedState.isArchived && archivedState.archivedTaskId && !items.some((t) => t.id === archivedState.archivedTaskId)) { + items.unshift({ + id: archivedState.archivedTaskId, + title: archivedState.archivedTaskTitle ?? 'Archived task', + state: undefined, + description: undefined, + workflowStepId: undefined, + repositoryPath: archivedState.archivedTaskRepositoryPath, + diffStats: undefined, + updatedAt: archivedState.archivedTaskUpdatedAt, + isArchived: true, + }); + } + + return items; + }, [repositoriesByWorkspace, allTasks, workspaceId, getSessionInfoForTask, archivedState]); const updateUrl = useCallback((sessionId: string) => { @@ -167,29 +185,16 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId window.history.replaceState({}, '', linkToSession(sessionId)); }, []); - const loadTaskSessionsForTask = useCallback( + const handleArchiveTask = useCallback( async (taskId: string) => { - const state = store.getState(); - if (state.taskSessionsByTask.loadedByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - if (state.taskSessionsByTask.loadingByTaskId[taskId]) { - return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; - } - setTaskSessionsLoading(taskId, true); try { - const response = await listTaskSessions(taskId, { cache: 'no-store' }); - setTaskSessionsForTask(taskId, response.sessions ?? []); - return response.sessions ?? []; + await archiveTaskById(taskId); + await removeTaskFromBoard(taskId); } catch (error) { - console.error('Failed to load task sessions:', error); - setTaskSessionsForTask(taskId, []); - return []; - } finally { - setTaskSessionsLoading(taskId, false); + console.error('Failed to archive task:', error); } }, - [setTaskSessionsForTask, setTaskSessionsLoading, store] + [archiveTaskById, removeTaskFromBoard] ); const handleDeleteTask = useCallback( @@ -197,67 +202,12 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId setDeletingTaskId(taskId); try { await deleteTaskById(taskId); - - // Remove task from multi snapshots - const currentSnapshots = store.getState().kanbanMulti.snapshots; - for (const [wfId, snapshot] of Object.entries(currentSnapshots)) { - const hadTask = snapshot.tasks.some((t: KanbanState['tasks'][number]) => t.id === taskId); - if (hadTask) { - store.getState().setWorkflowSnapshot(wfId, { - ...snapshot, - tasks: snapshot.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId), - }); - } - } - - // Also update single kanban state if task was there - const currentKanbanTasks = store.getState().kanban.tasks; - if (currentKanbanTasks.some((t: KanbanState['tasks'][number]) => t.id === taskId)) { - store.setState((state) => ({ - ...state, - kanban: { - ...state.kanban, - tasks: state.kanban.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId), - }, - })); - } - - // Collect all remaining tasks across snapshots - const allRemainingTasks: KanbanState['tasks'] = []; - for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) { - allRemainingTasks.push(...snapshot.tasks); - } - - // If deleted task was active, switch to another task or go home - const currentActiveTaskId = store.getState().tasks.activeTaskId; - if (currentActiveTaskId === taskId) { - const oldSessionId = store.getState().tasks.activeSessionId; - if (allRemainingTasks.length > 0) { - const nextTask = allRemainingTasks[0]; - if (nextTask.primarySessionId) { - setActiveSession(nextTask.id, nextTask.primarySessionId); - performLayoutSwitch(oldSessionId, nextTask.primarySessionId); - window.history.replaceState({}, '', linkToSession(nextTask.primarySessionId)); - } else { - const sessions = await loadTaskSessionsForTask(nextTask.id); - const sessionId = sessions[0]?.id ?? null; - if (sessionId) { - setActiveSession(nextTask.id, sessionId); - performLayoutSwitch(oldSessionId, sessionId); - window.history.replaceState({}, '', linkToSession(sessionId)); - } else { - setActiveTask(nextTask.id); - } - } - } else { - window.location.href = '/'; - } - } + await removeTaskFromBoard(taskId); } finally { setDeletingTaskId(null); } }, - [deleteTaskById, store, setActiveSession, setActiveTask, loadTaskSessionsForTask] + [deleteTaskById, removeTaskFromBoard] ); const handleSelectTask = useCallback( @@ -306,6 +256,7 @@ export const TaskSessionSidebar = memo(function TaskSessionSidebar({ workspaceId onSelectTask={(taskId) => { handleSelectTask(taskId); }} + onArchiveTask={handleArchiveTask} onDeleteTask={handleDeleteTask} deletingTaskId={deletingTaskId} isLoading={isLoadingWorkflow} diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index 66905b7ec9..289a7e1c5a 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -19,6 +19,7 @@ type TaskSwitcherItem = { repositoryPath?: string; diffStats?: DiffStats; updatedAt?: string; + isArchived?: boolean; }; type TaskSwitcherProps = { @@ -27,6 +28,7 @@ type TaskSwitcherProps = { activeTaskId: string | null; selectedTaskId: string | null; onSelectTask: (taskId: string) => void; + onArchiveTask?: (taskId: string) => void; onDeleteTask?: (taskId: string) => void; deletingTaskId?: string | null; isLoading?: boolean; @@ -49,6 +51,7 @@ export const TaskSwitcher = memo(function TaskSwitcher({ activeTaskId, selectedTaskId, onSelectTask, + onArchiveTask, onDeleteTask, deletingTaskId, isLoading = false, @@ -97,10 +100,12 @@ export const TaskSwitcher = memo(function TaskSwitcher({ description={repoLabel} stepName={stepName} state={task.state} + isArchived={task.isArchived} isSelected={isSelected} diffStats={task.diffStats} updatedAt={task.updatedAt} onClick={() => onSelectTask(task.id)} + onArchive={onArchiveTask ? () => onArchiveTask(task.id) : undefined} onDelete={onDeleteTask ? () => onDeleteTask(task.id) : undefined} isDeleting={deletingTaskId === task.id} /> diff --git a/apps/web/components/task/task-top-bar.tsx b/apps/web/components/task/task-top-bar.tsx index 8e6c2bc8b7..b04b2fdbc7 100644 --- a/apps/web/components/task/task-top-bar.tsx +++ b/apps/web/components/task/task-top-bar.tsx @@ -4,22 +4,22 @@ import { memo, useState } from 'react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { - IconArrowLeft, IconBug, IconCopy, - IconDots, IconGitBranch, IconCheck, + IconHome, IconSettings, } from '@tabler/icons-react'; import { Button } from '@kandev/ui/button'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, - DropdownMenuSeparator, -} from '@kandev/ui/dropdown-menu'; + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from '@kandev/ui/breadcrumb'; import { Tooltip, TooltipContent, TooltipTrigger } from '@kandev/ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from '@kandev/ui/popover'; import { CommitStatBadge } from '@/components/diff-stat'; @@ -50,6 +50,7 @@ type TaskTopBarProps = { workflowSteps?: WorkflowStepperStep[]; currentStepId?: string | null; workflowId?: string | null; + isArchived?: boolean; }; const TaskTopBar = memo(function TaskTopBar({ @@ -66,12 +67,12 @@ const TaskTopBar = memo(function TaskTopBar({ workflowSteps, currentStepId, workflowId, + isArchived, }: TaskTopBarProps) { const [copiedBranch, setCopiedBranch] = useState(false); const [popoverOpen, setPopoverOpen] = useState(false); const [copiedRepo, setCopiedRepo] = useState(false); const [copiedWorktree, setCopiedWorktree] = useState(false); - const [moreMenuOpen, setMoreMenuOpen] = useState(false); const router = useRouter(); const gitStatus = useSessionGitStatus(activeSessionId ?? null); @@ -87,8 +88,6 @@ const TaskTopBar = memo(function TaskTopBar({ } }; - // Preview controls are handled by a dedicated component. - const handleCopyRepo = (e: React.MouseEvent) => { e.stopPropagation(); if (repositoryPath) { @@ -108,21 +107,34 @@ const TaskTopBar = memo(function TaskTopBar({ }; return ( -
-
- - {repositoryName && ( - <> - {repositoryName} - › - - )} - {taskTitle ?? 'Task details'} +
+
+ + + + + + + + + + {repositoryName && ( + <> + + + {repositoryName} + + + )} + + + + {taskTitle ?? 'Task details'} + + + + + {displayBranch && ( @@ -237,9 +249,10 @@ const TaskTopBar = memo(function TaskTopBar({ currentStepId={currentStepId ?? null} taskId={taskId ?? null} workflowId={workflowId ?? null} + isArchived={isArchived} /> )} -
+
{DEBUG_UI && onToggleDebugOverlay && ( @@ -258,45 +271,30 @@ const TaskTopBar = memo(function TaskTopBar({ )} - - - - - {/* More Options (3-dot) Dropdown */} - - - - - - { - if (worktreePath) { - navigator.clipboard?.writeText(worktreePath); - } - }} - disabled={!worktreePath} - > - - Copy workspace path - - - + + + + )} + + + + + Settings +
diff --git a/apps/web/components/task/terminal-panel.tsx b/apps/web/components/task/terminal-panel.tsx index 1445e0e24d..590419a2be 100644 --- a/apps/web/components/task/terminal-panel.tsx +++ b/apps/web/components/task/terminal-panel.tsx @@ -6,6 +6,7 @@ import { PanelRoot, PanelBody } from './panel-primitives'; import { PassthroughTerminal } from './passthrough-terminal'; import { ShellTerminal } from './shell-terminal'; import { useAppStore } from '@/components/state-provider'; +import { useIsTaskArchived, ArchivedPanelPlaceholder } from './task-archived-context'; export const TerminalPanel = memo(function TerminalPanel( props: IDockviewPanelProps<{ terminalId: string; type?: 'shell' | 'dev-server'; processId?: string }> @@ -26,6 +27,9 @@ export const TerminalPanel = memo(function TerminalPanel( processId ? state.processes.processesById[processId] : undefined ); const isStopping = devProcess?.status === 'stopping'; + const isArchived = useIsTaskArchived(); + + if (isArchived) return ; if (type === 'dev-server' && processId) { return ( diff --git a/apps/web/components/task/workflow-stepper.tsx b/apps/web/components/task/workflow-stepper.tsx index 733474ed6f..91738e395b 100644 --- a/apps/web/components/task/workflow-stepper.tsx +++ b/apps/web/components/task/workflow-stepper.tsx @@ -29,6 +29,7 @@ type WorkflowStepperProps = { currentStepId: string | null; taskId?: string | null; workflowId?: string | null; + isArchived?: boolean; }; const WorkflowStepper = memo(function WorkflowStepper({ @@ -36,6 +37,7 @@ const WorkflowStepper = memo(function WorkflowStepper({ currentStepId, taskId, workflowId, + isArchived, }: WorkflowStepperProps) { const [movingToStepId, setMovingToStepId] = useState(null); @@ -73,13 +75,13 @@ const WorkflowStepper = memo(function WorkflowStepper({ return (
{sortedSteps.map((step, index) => { - const isCompleted = currentIndex >= 0 && index < currentIndex; - const isCurrent = index === currentIndex; + const isCompleted = !isArchived && currentIndex >= 0 && index < currentIndex; + const isCurrent = !isArchived && index === currentIndex; const isAdjacent = currentIndex >= 0 && (index === currentIndex - 1 || index === currentIndex + 1); const canMove = - !isCurrent && taskId && workflowId && (isAdjacent || step.allow_manual_move); + !isArchived && !isCurrent && taskId && workflowId && (isAdjacent || step.allow_manual_move); return (
@@ -161,6 +163,14 @@ const WorkflowStepper = memo(function WorkflowStepper({
); })} + {isArchived && ( + <> +
+ + Archived + + + )}
); }); diff --git a/apps/web/hooks/use-task-actions.ts b/apps/web/hooks/use-task-actions.ts index 31eb09c7a2..bfeee85c1e 100644 --- a/apps/web/hooks/use-task-actions.ts +++ b/apps/web/hooks/use-task-actions.ts @@ -1,5 +1,5 @@ import { useCallback } from 'react'; -import { deleteTask, moveTask } from '@/lib/api'; +import { archiveTask, deleteTask, moveTask } from '@/lib/api'; type MovePayload = { workflow_id: string; workflow_step_id: string; position: number }; @@ -12,5 +12,9 @@ export function useTaskActions() { return deleteTask(taskId); }, []); - return { moveTaskById, deleteTaskById }; + const archiveTaskById = useCallback(async (taskId: string) => { + return archiveTask(taskId); + }, []); + + return { moveTaskById, deleteTaskById, archiveTaskById }; } diff --git a/apps/web/hooks/use-task-removal.ts b/apps/web/hooks/use-task-removal.ts new file mode 100644 index 0000000000..0bf1584863 --- /dev/null +++ b/apps/web/hooks/use-task-removal.ts @@ -0,0 +1,119 @@ +import { useCallback } from 'react'; +import type { StoreApi } from 'zustand'; +import type { AppState } from '@/lib/state/store'; +import type { KanbanState } from '@/lib/state/slices'; +import { linkToSession } from '@/lib/links'; +import { listTaskSessions } from '@/lib/api'; +import { performLayoutSwitch } from '@/lib/state/dockview-store'; + +type TaskRemovalOptions = { + store: StoreApi; + /** Whether to call performLayoutSwitch when switching sessions (desktop sidebar uses this) */ + useLayoutSwitch?: boolean; +}; + +/** + * Hook that provides shared logic for removing a task from the kanban board + * (after archive or delete) and switching to the next available task. + * + * Used by both TaskSessionSidebar and SessionTaskSwitcherSheet. + */ +export function useTaskRemoval({ store, useLayoutSwitch = false }: TaskRemovalOptions) { + const loadTaskSessionsForTask = useCallback( + async (taskId: string) => { + const state = store.getState(); + if (state.taskSessionsByTask.loadedByTaskId[taskId]) { + return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + } + if (state.taskSessionsByTask.loadingByTaskId[taskId]) { + return state.taskSessionsByTask.itemsByTaskId[taskId] ?? []; + } + store.getState().setTaskSessionsLoading(taskId, true); + try { + const response = await listTaskSessions(taskId, { cache: 'no-store' }); + store.getState().setTaskSessionsForTask(taskId, response.sessions ?? []); + return response.sessions ?? []; + } catch (error) { + console.error('Failed to load task sessions:', error); + store.getState().setTaskSessionsForTask(taskId, []); + return []; + } finally { + store.getState().setTaskSessionsLoading(taskId, false); + } + }, + [store] + ); + + /** + * Remove a task from the kanban board state (both single and multi snapshots) + * and switch to the next available task if the removed task was active. + */ + const removeTaskFromBoard = useCallback( + async (taskId: string) => { + const { setActiveSession, setActiveTask } = store.getState(); + + // Remove task from multi snapshots + const currentSnapshots = store.getState().kanbanMulti.snapshots; + for (const [wfId, snapshot] of Object.entries(currentSnapshots)) { + const hadTask = snapshot.tasks.some((t: KanbanState['tasks'][number]) => t.id === taskId); + if (hadTask) { + store.getState().setWorkflowSnapshot(wfId, { + ...snapshot, + tasks: snapshot.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId), + }); + } + } + + // Also update single kanban state + const currentKanbanTasks = store.getState().kanban.tasks; + if (currentKanbanTasks.some((t: KanbanState['tasks'][number]) => t.id === taskId)) { + store.setState((state) => ({ + ...state, + kanban: { + ...state.kanban, + tasks: state.kanban.tasks.filter((t: KanbanState['tasks'][number]) => t.id !== taskId), + }, + })); + } + + // Collect remaining tasks across snapshots + const allRemainingTasks: KanbanState['tasks'] = []; + for (const snapshot of Object.values(store.getState().kanbanMulti.snapshots)) { + allRemainingTasks.push(...snapshot.tasks); + } + // Fallback to single kanban if no multi-snapshots + if (allRemainingTasks.length === 0) { + allRemainingTasks.push(...store.getState().kanban.tasks); + } + + // If removed task was active, switch to another task or go home + const currentActiveTaskId = store.getState().tasks.activeTaskId; + if (currentActiveTaskId === taskId) { + const oldSessionId = store.getState().tasks.activeSessionId; + if (allRemainingTasks.length > 0) { + const nextTask = allRemainingTasks[0]; + if (nextTask.primarySessionId) { + setActiveSession(nextTask.id, nextTask.primarySessionId); + if (useLayoutSwitch) performLayoutSwitch(oldSessionId, nextTask.primarySessionId); + window.history.replaceState({}, '', linkToSession(nextTask.primarySessionId)); + } else { + const sessions = await loadTaskSessionsForTask(nextTask.id); + const sessionId = sessions[0]?.id ?? null; + if (sessionId) { + setActiveSession(nextTask.id, sessionId); + if (useLayoutSwitch) performLayoutSwitch(oldSessionId, sessionId); + window.history.replaceState({}, '', linkToSession(sessionId)); + } else { + setActiveTask(nextTask.id); + } + } + } else { + window.location.href = '/'; + } + } + }, + [store, useLayoutSwitch, loadTaskSessionsForTask] + ); + + return { removeTaskFromBoard, loadTaskSessionsForTask }; +} diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 90cffa9983..afa3cd73ba 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -94,9 +94,16 @@ export async function fetchTask(taskId: string, options?: ApiRequestOptions) { return fetchJson(`/api/v1/tasks/${taskId}`, options); } +export async function archiveTask(taskId: string, options?: ApiRequestOptions) { + return fetchJson(`/api/v1/tasks/${taskId}/archive`, { + ...options, + init: { method: 'POST', ...(options?.init ?? {}) }, + }); +} + export async function listTasksByWorkspace( workspaceId: string, - params: { page?: number; pageSize?: number; query?: string } = {}, + params: { page?: number; pageSize?: number; query?: string; includeArchived?: boolean } = {}, options?: ApiRequestOptions ) { const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; @@ -104,5 +111,6 @@ export async function listTasksByWorkspace( if (params.page) url.searchParams.set('page', String(params.page)); if (params.pageSize) url.searchParams.set('page_size', String(params.pageSize)); if (params.query) url.searchParams.set('query', params.query); + if (params.includeArchived) url.searchParams.set('include_archived', 'true'); return fetchJson(url.toString(), options); } diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index 2e807d4c84..37fb2451a5 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -85,6 +85,7 @@ export type TaskEventPayload = { primary_session_id?: string | null; session_count?: number | null; review_status?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null; + archived_at?: string | null; updated_at?: string; }; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 74f59b55c0..b36d36df18 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -79,6 +79,7 @@ export type WorkflowStep = { events?: StepEvents; allow_manual_move?: boolean; is_start_step?: boolean; + auto_archive_after_hours?: number; created_at: string; updated_at: string; }; @@ -219,6 +220,7 @@ export type Task = { primary_session_id?: string | null; session_count?: number | null; review_status?: 'pending' | 'approved' | 'changes_requested' | 'rejected' | null; + archived_at?: string | null; created_at: string; updated_at: string; metadata?: Record | null; @@ -240,6 +242,7 @@ export type WorkflowStepDTO = { events?: StepEvents; allow_manual_move: boolean; is_start_step?: boolean; + auto_archive_after_hours?: number; created_at?: string; updated_at?: string; }; diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 0626dcf7e0..093cb7550b 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -81,10 +81,41 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { 'task.updated': (message) => { store.setState((state) => { const wfId = message.payload.workflow_id; + const taskId = message.payload.task_id; let next = state; + // If the task was archived, remove it from the kanban board (like delete) + if (message.payload.archived_at) { + if (state.kanban.workflowId === wfId) { + next = { + ...next, + kanban: { + ...next.kanban, + tasks: next.kanban.tasks.filter((t) => t.id !== taskId), + }, + }; + } + const snapshot = state.kanbanMulti.snapshots[wfId]; + if (snapshot) { + next = { + ...next, + kanbanMulti: { + ...next.kanbanMulti, + snapshots: { + ...next.kanbanMulti.snapshots, + [wfId]: { + ...snapshot, + tasks: snapshot.tasks.filter((t) => t.id !== taskId), + }, + }, + }, + }; + } + return next; + } + if (state.kanban.workflowId === wfId) { - const existing = state.kanban.tasks.find((task) => task.id === message.payload.task_id); + const existing = state.kanban.tasks.find((task) => task.id === taskId); const nextTask = buildTaskFromPayload(message.payload, existing); next = { ...next, @@ -94,7 +125,7 @@ export function registerTasksHandlers(store: StoreApi): WsHandlers { const snapshot = state.kanbanMulti.snapshots[wfId]; if (snapshot) { - const existing = snapshot.tasks.find((task) => task.id === message.payload.task_id); + const existing = snapshot.tasks.find((task) => task.id === taskId); const nextTask = buildTaskFromPayload(message.payload, existing); next = upsertMultiTask(next, wfId, nextTask); }