feat: archive tasks (#232)
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¬Null, &dfltValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "archived_at" {
|
||||
hasColumn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := r.db.Exec("ALTER TABLE tasks ADD COLUMN archived_at DATETIME"); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = r.db.Exec("CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at)")
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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, ¬Null, &dfltValue, &pk); err != nil {
|
||||
return err
|
||||
}
|
||||
if name == "auto_archive_after_hours" {
|
||||
hasColumn = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if hasColumn {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err = r.db.Exec("ALTER TABLE workflow_steps ADD COLUMN auto_archive_after_hours INTEGER DEFAULT 0")
|
||||
return err
|
||||
}
|
||||
|
||||
// convertOldStepToEvents converts old boolean/pointer fields to new event-driven model.
|
||||
func convertOldStepToEvents(autoStart, planMode, requireApproval bool, onCompleteStepID, onApprovalStepID sql.NullString) models.StepEvents {
|
||||
events := models.StepEvents{}
|
||||
@@ -871,11 +910,11 @@ func (r *Repository) CreateStep(ctx context.Context, step *models.WorkflowStep)
|
||||
_, err = r.db.ExecContext(ctx, `
|
||||
INSERT INTO workflow_steps (
|
||||
id, workflow_id, name, position, color,
|
||||
prompt, events, allow_manual_move, is_start_step, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`, step.ID, step.WorkflowID, step.Name, step.Position, step.Color,
|
||||
step.Prompt, string(eventsJSON), sqlite.BoolToInt(step.AllowManualMove),
|
||||
sqlite.BoolToInt(step.IsStartStep), step.CreatedAt, step.UpdatedAt)
|
||||
sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.CreatedAt, step.UpdatedAt)
|
||||
|
||||
return err
|
||||
}
|
||||
@@ -886,10 +925,11 @@ func (r *Repository) scanStep(row interface {
|
||||
}) (*models.WorkflowStep, error) {
|
||||
step := &models.WorkflowStep{}
|
||||
var allowManualMove, isStartStep int
|
||||
var autoArchiveAfterHours sql.NullInt64
|
||||
var color, prompt, eventsJSON sql.NullString
|
||||
|
||||
err := row.Scan(&step.ID, &step.WorkflowID, &step.Name, &step.Position, &color,
|
||||
&prompt, &eventsJSON, &allowManualMove, &isStartStep, &step.CreatedAt, &step.UpdatedAt)
|
||||
&prompt, &eventsJSON, &allowManualMove, &isStartStep, &autoArchiveAfterHours, &step.CreatedAt, &step.UpdatedAt)
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -897,6 +937,9 @@ func (r *Repository) scanStep(row interface {
|
||||
|
||||
step.AllowManualMove = allowManualMove == 1
|
||||
step.IsStartStep = isStartStep == 1
|
||||
if autoArchiveAfterHours.Valid {
|
||||
step.AutoArchiveAfterHours = int(autoArchiveAfterHours.Int64)
|
||||
}
|
||||
if color.Valid {
|
||||
step.Color = color.String
|
||||
}
|
||||
@@ -912,7 +955,7 @@ func (r *Repository) scanStep(row interface {
|
||||
return step, nil
|
||||
}
|
||||
|
||||
const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, created_at, updated_at`
|
||||
const stepSelectColumns = `id, workflow_id, name, position, color, prompt, events, allow_manual_move, is_start_step, auto_archive_after_hours, created_at, updated_at`
|
||||
|
||||
// GetStep retrieves a workflow step by ID.
|
||||
func (r *Repository) GetStep(ctx context.Context, id string) (*models.WorkflowStep, error) {
|
||||
@@ -944,11 +987,11 @@ func (r *Repository) UpdateStep(ctx context.Context, step *models.WorkflowStep)
|
||||
UPDATE workflow_steps SET
|
||||
name = ?, position = ?, color = ?,
|
||||
prompt = ?, events = ?,
|
||||
allow_manual_move = ?, is_start_step = ?, updated_at = ?
|
||||
allow_manual_move = ?, is_start_step = ?, auto_archive_after_hours = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`, step.Name, step.Position, step.Color,
|
||||
step.Prompt, string(eventsJSON),
|
||||
sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.UpdatedAt, step.ID)
|
||||
sqlite.BoolToInt(step.AllowManualMove), sqlite.BoolToInt(step.IsStartStep), step.AutoArchiveAfterHours, step.UpdatedAt, step.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user