feat: archive tasks (#232)

This commit is contained in:
Carlos Florêncio
2026-02-16 21:46:25 +00:00
committed by GitHub
parent aac9d0610e
commit 3af7a692bf
45 changed files with 1311 additions and 460 deletions
+3
View File
@@ -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{})
@@ -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
);
@@ -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
@@ -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) {
+21 -12
View File
@@ -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{}
@@ -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
}
+13 -10
View File
@@ -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
+9 -4
View File
@@ -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 {
@@ -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
}
@@ -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"`
@@ -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"`
}
@@ -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)
@@ -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, &notNull, &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
}
@@ -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)
@@ -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)
}
}
@@ -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))
}
}
}
+108 -2
View File
@@ -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
}
@@ -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
}
+21 -19
View File
@@ -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.
@@ -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, &notNull, &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
}
+1
View File
@@ -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"
+4 -1
View File
@@ -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<Pick<WorkflowStep, 'name' | 'position' | 'color' | 'prompt' | 'events' | 'allow_manual_move' | 'is_start_step'>>
payload: Partial<Pick<WorkflowStep, 'name' | 'position' | 'color' | 'prompt' | 'events' | 'allow_manual_move' | 'is_start_step' | 'auto_archive_after_hours'>>
): Promise<WorkflowStep> {
const body: Record<string, unknown> = {};
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<BackendWorkflowStep>(`${apiBaseUrl}/api/v1/workflow/steps/${stepId}`, {
method: 'PUT',
body: JSON.stringify(body),
+96 -75
View File
@@ -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<TaskWithResolution>[] {
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 (
<Link
href={`/s/${sessionId}`}
className="text-primary hover:underline font-medium"
>
{task.title}
</Link>
)
}
return <span className="font-medium">{task.title}</span>
const isArchived = !!task.archived_at
const repoName = task.repositories?.[0]
? repoMap.get(task.repositories[0].repository_id)
: undefined
return (
<div className="flex flex-col gap-0.5 py-0.5">
<div className="flex items-center gap-2">
{sessionId ? (
<Link
href={`/s/${sessionId}`}
className="text-primary font-medium text-sm"
>
{task.title}
</Link>
) : (
<span className="font-medium text-sm">{task.title}</span>
)}
{isArchived && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0 text-amber-500 border-amber-500/30">
Archived
</Badge>
)}
</div>
{repoName && (
<span className="text-xs text-muted-foreground/60">{repoName}</span>
)}
</div>
)
},
},
{
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 (
<TooltipProvider>
<div className="flex flex-wrap gap-1">
{repoInfos.map((info, idx) => (
<Tooltip key={idx}>
<TooltipTrigger asChild>
<span className="cursor-default hover:underline">{info.name}</span>
</TooltipTrigger>
<TooltipContent>
<p className="font-mono text-xs">{info.path || 'No path'}</p>
</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
<span className="text-xs text-muted-foreground">
{name || "-"}
</span>
)
},
},
{
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 <Badge variant={variant}>{state}</Badge>
const stepName = stepMap.get(row.original.workflow_step_id)
return (
<span className="text-xs text-muted-foreground bg-foreground/[0.06] px-2 py-0.5 rounded-md">
{stepName || "-"}
</span>
)
},
},
{
@@ -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 (
<span className="text-xs text-muted-foreground">
{formatDistanceToNow(date, { addSuffix: true })}
</span>
)
},
},
{
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 (
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation()
onDelete(row.original.id)
}}
>
{isDeleting ? (
<IconLoader className="h-4 w-4 animate-spin" />
) : (
<IconTrash className="h-4 w-4 text-destructive" />
<div className="flex items-center justify-end gap-0.5">
{!isArchived && (
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="cursor-pointer h-7 w-7 p-0"
onClick={(e) => {
e.stopPropagation()
onArchive(task.id)
}}
>
<IconArchive className="h-3.5 w-3.5 text-muted-foreground" />
</Button>
</TooltipTrigger>
<TooltipContent>Archive</TooltipContent>
</Tooltip>
)}
</Button>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="cursor-pointer h-7 w-7 p-0"
disabled={isDeleting}
onClick={(e) => {
e.stopPropagation()
onDelete(task.id)
}}
>
{isDeleting ? (
<IconLoader className="h-3.5 w-3.5 animate-spin" />
) : (
<IconTrash className="h-3.5 w-3.5 text-destructive" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>Delete</TooltipContent>
</Tooltip>
</div>
)
},
},
+60 -21
View File
@@ -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<string | null>(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<PaginationState>({
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}
/>
<div className="flex-1 overflow-auto px-6 py-4">
<div className="mb-4">
<h1 className="text-xl font-semibold">All Tasks</h1>
<p className="text-sm text-muted-foreground">
{total} task{total !== 1 ? 's' : ''} found
</p>
</div>
<div className="flex-1 overflow-auto px-6 py-6">
<div className="mx-auto max-w-5xl">
<div className="mb-5 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">All Tasks</h1>
<p className="text-sm text-muted-foreground">
{total} task{total !== 1 ? 's' : ''} found
</p>
</div>
<div className="flex items-center gap-2">
<Checkbox
id="show-archived"
checked={showArchived}
onCheckedChange={(checked) => setShowArchived(checked === true)}
/>
<Label htmlFor="show-archived" className="text-sm cursor-pointer">
Show archived
</Label>
</div>
</div>
<DataTable
columns={columns}
data={tasks}
pageCount={pageCount}
pagination={pagination}
onPaginationChange={setPagination}
isLoading={isLoading}
onRowClick={handleRowClick}
/>
<DataTable
columns={columns}
data={tasks}
pageCount={pageCount}
pagination={pagination}
onPaginationChange={setPagination}
isLoading={isLoading}
onRowClick={handleRowClick}
/>
</div>
</div>
{activeWorkspaceId && defaultWorkflow && defaultStep && (
@@ -482,6 +482,39 @@ function StepConfigPanel({ step, steps, onUpdate, onRemove, readOnly = false }:
<HelpTip text="Allow dragging tasks into this step on the board." />
</div>
</div>
{/* Auto-archive setting */}
<div className="flex items-center gap-2 pt-1">
<Checkbox
id={`${step.id}-auto-archive`}
checked={(step.auto_archive_after_hours ?? 0) > 0}
onCheckedChange={(checked) => {
if (readOnly) return;
onUpdate({ auto_archive_after_hours: checked ? 24 : 0 });
}}
disabled={readOnly}
/>
<Label htmlFor={`${step.id}-auto-archive`} className="text-sm">Auto-archive</Label>
<HelpTip text="Automatically archive tasks after they have been in this step for a set number of hours. Useful for the last step of a workflow (e.g., Done) to keep the board clean." />
{(step.auto_archive_after_hours ?? 0) > 0 && (
<>
<span className="text-sm text-muted-foreground">after</span>
<Input
id={`${step.id}-auto-archive-hours`}
type="number"
min={1}
className="w-20 h-7 text-sm"
value={step.auto_archive_after_hours ?? 24}
onChange={(e) => {
if (readOnly) return;
const val = parseInt(e.target.value, 10);
onUpdate({ auto_archive_after_hours: isNaN(val) || val < 1 ? 1 : val });
}}
disabled={readOnly}
/>
<span className="text-sm text-muted-foreground">hours</span>
</>
)}
</div>
</div>
{/* Transitions section */}
@@ -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<string | null>(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 <ArchivedPanelPlaceholder />;
return (
<PanelRoot>
<PanelBody>
+4
View File
@@ -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 <ArchivedPanelPlaceholder />;
return (
<PanelRoot>
<PanelBody>
@@ -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}
@@ -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<TaskArchivedState>({ 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 (
<PanelRoot>
<PanelBody>
<div className="flex items-center justify-center h-full text-muted-foreground text-xs">
{message}
</div>
</PanelBody>
</PanelRoot>
);
});
@@ -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 <ArchivedPanelPlaceholder />;
return (
<PanelRoot>
{/* Top bar */}
@@ -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<ChatInputContainerHandle>(null);
@@ -479,6 +481,11 @@ export const TaskChatPanel = memo(function TaskChatPanel({
</PanelBody>
{/* Sticky input at bottom */}
{isArchived ? (
<div className="bg-muted/50 flex-shrink-0 px-4 py-3 text-center text-sm text-muted-foreground border-t">
This task is archived and read-only.
</div>
) : (
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
<ChatInputContainer
ref={chatInputRef}
@@ -526,6 +533,7 @@ export const TaskChatPanel = memo(function TaskChatPanel({
isPanelFocused={isPanelFocused}
/>
</div>
)}
</PanelRoot>
);
});
+12 -1
View File
@@ -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({
<IconEye className="mr-2 h-4 w-4" />
Review
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
onArchive?.();
}}
>
<IconArchive className="mr-2 h-4 w-4" />
Archive
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
+11 -2
View File
@@ -16,6 +16,7 @@ type TaskItemProps = {
description?: string;
stepName?: string;
state?: TaskState;
isArchived?: boolean;
isSelected?: boolean;
onClick?: () => 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 ? (
<span className="text-[10px] text-muted-foreground/70 bg-amber-500/15 text-amber-500 px-1.5 py-px rounded-[6px]">
Archived
</span>
) : stepName ? (
<span className="text-[10px] text-muted-foreground/70 bg-foreground/[0.06] px-1.5 py-px rounded-[6px]">
{stepName}
</span>
)}
) : null}
{renderRightMeta()}
</div>
@@ -162,6 +170,7 @@ export const TaskItem = memo(function TaskItem({
onRename={onRename}
onDuplicate={onDuplicate}
onReview={onReview}
onArchive={onArchive}
onDelete={onDelete}
isDeleting={isDeleting}
/>
+24 -12
View File
@@ -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 <div className="h-screen w-full bg-background" />;
}
@@ -270,21 +279,24 @@ export function TaskPageContent({
workflowSteps={workflowSteps}
currentStepId={task?.workflow_step_id ?? null}
workflowId={task?.workflow_id ?? null}
isArchived={!!task?.archived_at}
/>
)}
<TaskLayout
workspaceId={task?.workspace_id ?? null}
workflowId={task?.workflow_id ?? null}
sessionId={effectiveSessionId ?? null}
repository={repository ?? null}
initialScripts={initialScripts}
initialTerminals={initialTerminals}
defaultLayouts={defaultLayouts}
taskTitle={task?.title}
baseBranch={task?.repositories?.[0]?.base_branch}
worktreeBranch={worktreeBranch}
/>
<TaskArchivedProvider value={archivedValue}>
<TaskLayout
workspaceId={task?.workspace_id ?? null}
workflowId={task?.workflow_id ?? null}
sessionId={effectiveSessionId ?? null}
repository={repository ?? null}
initialScripts={initialScripts}
initialTerminals={initialTerminals}
defaultLayouts={defaultLayouts}
taskTitle={task?.title}
baseBranch={task?.repositories?.[0]?.base_branch}
worktreeBranch={worktreeBranch}
/>
</TaskArchivedProvider>
</div>
</TooltipProvider>
);
@@ -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<string | null>(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}
@@ -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}
/>
+61 -63
View File
@@ -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 (
<header className="flex items-center justify-between px-3 py-1 border-b border-border">
<div className="flex items-center gap-3">
<Button variant="ghost" size="sm" asChild>
<Link href="/">
<IconArrowLeft className="h-4 w-4" />
Back
</Link>
</Button>
{repositoryName && (
<>
<span className="text-sm text-muted-foreground">{repositoryName}</span>
<span className="text-sm text-muted-foreground">›</span>
</>
)}
<span className="text-sm font-medium">{taskTitle ?? 'Task details'}</span>
<header className="grid grid-cols-[1fr_auto_1fr] items-center px-3 py-1 border-b border-border">
<div className="flex items-center gap-2.5 min-w-0">
<Breadcrumb>
<BreadcrumbList className="flex-nowrap text-sm">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link href="/" className="text-muted-foreground hover:text-foreground transition-colors">
<IconHome className="h-4 w-4" />
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
{repositoryName && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<span className="text-muted-foreground">{repositoryName}</span>
</BreadcrumbItem>
</>
)}
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage className="font-medium">
{taskTitle ?? 'Task details'}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
{displayBranch && (
<Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
<Tooltip>
@@ -237,9 +249,10 @@ const TaskTopBar = memo(function TaskTopBar({
currentStepId={currentStepId ?? null}
taskId={taskId ?? null}
workflowId={workflowId ?? null}
isArchived={isArchived}
/>
)}
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 justify-end">
{DEBUG_UI && onToggleDebugOverlay && (
<Tooltip>
<TooltipTrigger asChild>
@@ -258,45 +271,30 @@ const TaskTopBar = memo(function TaskTopBar({
</Tooltip>
)}
<DocumentControls activeSessionId={activeSessionId ?? null} />
<EditorsMenu activeSessionId={activeSessionId ?? null} />
<VcsSplitButton
sessionId={activeSessionId ?? null}
baseBranch={baseBranch}
taskTitle={taskTitle}
displayBranch={displayBranch}
/>
{/* More Options (3-dot) Dropdown */}
<DropdownMenu open={moreMenuOpen} onOpenChange={setMoreMenuOpen}>
<DropdownMenuTrigger asChild>
<Button size="sm" variant={moreMenuOpen ? "default" : "outline"} className="cursor-pointer px-2">
<IconDots className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-[220px]">
<DropdownMenuItem
className="cursor-pointer"
onClick={() => {
if (worktreePath) {
navigator.clipboard?.writeText(worktreePath);
}
}}
disabled={!worktreePath}
>
<IconCopy className="h-4 w-4" />
Copy workspace path
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="cursor-pointer"
{!isArchived && (
<>
<EditorsMenu activeSessionId={activeSessionId ?? null} />
<VcsSplitButton
sessionId={activeSessionId ?? null}
baseBranch={baseBranch}
taskTitle={taskTitle}
displayBranch={displayBranch}
/>
</>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
size="sm"
variant="outline"
className="cursor-pointer px-2"
onClick={() => router.push('/settings/general')}
>
<IconSettings className="h-4 w-4" />
Open Settings
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</Button>
</TooltipTrigger>
<TooltipContent>Settings</TooltipContent>
</Tooltip>
</div>
</header>
@@ -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 <ArchivedPanelPlaceholder message="Terminal not available — this task is archived" />;
if (type === 'dev-server' && processId) {
return (
+13 -3
View File
@@ -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<string | null>(null);
@@ -73,13 +75,13 @@ const WorkflowStepper = memo(function WorkflowStepper({
return (
<div className="flex items-center gap-0 overflow-x-auto flex-shrink min-w-0">
{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 (
<div key={step.id} className="flex items-center">
@@ -161,6 +163,14 @@ const WorkflowStepper = memo(function WorkflowStepper({
</div>
);
})}
{isArchived && (
<>
<div className="h-px w-6 shrink-0 bg-border" />
<span className="text-[11px] font-medium text-amber-500 bg-amber-500/15 px-2 py-0.5 rounded-md whitespace-nowrap">
Archived
</span>
</>
)}
</div>
);
});
+6 -2
View File
@@ -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 };
}
+119
View File
@@ -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<AppState>;
/** 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 };
}
+9 -1
View File
@@ -94,9 +94,16 @@ export async function fetchTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<Task>(`/api/v1/tasks/${taskId}`, options);
}
export async function archiveTask(taskId: string, options?: ApiRequestOptions) {
return fetchJson<void>(`/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<ListTasksResponse>(url.toString(), options);
}
+1
View File
@@ -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;
};
+3
View File
@@ -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<string, unknown> | 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;
};
+33 -2
View File
@@ -81,10 +81,41 @@ export function registerTasksHandlers(store: StoreApi<AppState>): 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<AppState>): 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);
}