diff --git a/apps/backend/internal/backendapp/boot_state_routes.go b/apps/backend/internal/backendapp/boot_state_routes.go index 89ff0463a7..dc6ebce9ba 100644 --- a/apps/backend/internal/backendapp/boot_state_routes.go +++ b/apps/backend/internal/backendapp/boot_state_routes.go @@ -28,13 +28,19 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque settingsWorkspaceID := "" settingsWorkflowID := "" settingsRepositoryID := "" + settingsTasksListSort := "" + settingsTasksListGroup := "" if hasSettings { settingsWorkspaceID = settings.Settings.WorkspaceID settingsWorkflowID = settings.Settings.WorkflowFilterID + settingsTasksListSort = settings.Settings.TasksListSort + settingsTasksListGroup = settings.Settings.TasksListGroup if len(settings.Settings.RepositoryIDs) > 0 { settingsRepositoryID = settings.Settings.RepositoryIDs[0] } } + tasksListSort := tasksListSortForRoute(queryValue(req, "sort"), settingsTasksListSort) + tasksListGroup := tasksListGroupForRoute(queryValue(req, "group"), settingsTasksListGroup) workspaceIDs := workspaceIDSet(workspaces) activeWorkspaceID := firstValidID( workspaceIDs, @@ -54,7 +60,7 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque state["userSettings"] = mapUserSettingsState(settings, activeWorkspaceID) } if activeWorkspaceID == "" { - return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "tasks": []any{}, "total": 0} + return state, map[string]any{"activeWorkspaceId": nil, "workflows": []any{}, "steps": []any{}, "repositories": []any{}, "tasks": []any{}, "total": 0, "tasksListSort": tasksListSort, "tasksListGroup": tasksListGroup} } workflows, err := b.p.taskSvc.ListWorkflows(ctx, activeWorkspaceID, false) if err != nil { @@ -69,7 +75,7 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque } repositories := b.repositoriesForState(ctx, activeWorkspaceID, state) steps := b.workflowStepsForWorkspace(ctx, activeWorkspaceID) - tasks, total := b.tasksForWorkspace(ctx, activeWorkspaceID, activeWorkflowID, settingsRepositoryID) + tasks, total := b.tasksForWorkspace(ctx, activeWorkspaceID, activeWorkflowID, settingsRepositoryID, tasksListSort) routeData := map[string]any{ "activeWorkspaceId": activeWorkspaceID, "workflows": workflowsToDTOs(workflows), @@ -77,6 +83,8 @@ func (b bootStateBuilder) tasksPageBootData(ctx context.Context, req *http.Reque "repositories": repositories, "tasks": tasks, "total": total, + "tasksListSort": tasksListSort, + "tasksListGroup": tasksListGroup, } return state, routeData } @@ -237,8 +245,8 @@ func (b bootStateBuilder) workflowStepsForWorkspace(ctx context.Context, workspa return items } -func (b bootStateBuilder) tasksForWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID string) ([]taskdto.TaskDTO, int) { - tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, "", 1, 25, false, false, false, false) +func (b bootStateBuilder) tasksForWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, sort string) ([]taskdto.TaskDTO, int) { + tasks, total, err := b.p.taskSvc.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, "", 1, 25, sort, false, false, false, false) if err != nil { b.logBootError("list tasks page tasks", err) return []taskdto.TaskDTO{}, 0 @@ -438,6 +446,8 @@ func mapUserSettingsState(response userdto.UserSettingsResponse, workspaceID str "kanbanViewMode": nullString(settings.KanbanViewMode), "workflowId": nullString(settings.WorkflowFilterID), "repositoryIds": stringSlice(settings.RepositoryIDs), + "tasksListSort": usermodels.NormalizeTasksListSort(settings.TasksListSort), + "tasksListGroup": usermodels.NormalizeTasksListGroup(settings.TasksListGroup), "preferredShell": nullString(settings.PreferredShell), "shellOptions": response.ShellOptions, "defaultEditorId": nullString(settings.DefaultEditorID), @@ -635,6 +645,20 @@ func queryValue(req *http.Request, name string) string { return strings.TrimSpace(parsed.Query().Get(name)) } +func tasksListSortForRoute(queryValue, settingsValue string) string { + if usermodels.IsValidTasksListSort(queryValue) { + return strings.TrimSpace(queryValue) + } + return usermodels.NormalizeTasksListSort(settingsValue) +} + +func tasksListGroupForRoute(queryValue, settingsValue string) string { + if usermodels.IsValidTasksListGroup(queryValue) { + return strings.TrimSpace(queryValue) + } + return usermodels.NormalizeTasksListGroup(settingsValue) +} + func readActiveWorkspaceCookie(req *http.Request) string { if req == nil { return "" diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index fbdc7ade9d..581a70ac53 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -122,7 +122,7 @@ func handleE2EReset( // accumulate across tests in the same Playwright worker and // eventually exhaust the per-worker port range. const resetPageSize = 10000 - tasks, total, err := repo.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, resetPageSize, true, true, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, resetPageSize, "", true, true, false, false) if err != nil { log.Error("e2e reset: failed to list tasks", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) diff --git a/apps/backend/internal/office/service/event_subscribers_fallback_test.go b/apps/backend/internal/office/service/event_subscribers_fallback_test.go index 2e523d7fbc..304d8c5e51 100644 --- a/apps/backend/internal/office/service/event_subscribers_fallback_test.go +++ b/apps/backend/internal/office/service/event_subscribers_fallback_test.go @@ -29,7 +29,7 @@ func (s *stubTaskWorkspace) DeleteWorkspace(_ context.Context, _ string) error { return nil } func (s *stubTaskWorkspace) ListTasksByWorkspace( - _ context.Context, _, _, _, _ string, _, _ int, _, _, _, _ bool, + _ context.Context, _, _, _, _ string, _, _ int, _ string, _, _, _, _ bool, ) ([]*taskmodels.Task, int, error) { return nil, 0, nil } diff --git a/apps/backend/internal/office/service/service.go b/apps/backend/internal/office/service/service.go index a7de9e185e..f0f0239ab3 100644 --- a/apps/backend/internal/office/service/service.go +++ b/apps/backend/internal/office/service/service.go @@ -103,7 +103,7 @@ type TaskWorkspaceService interface { GetWorkspace(ctx context.Context, id string) (*taskmodels.Workspace, error) ListWorkspaces(ctx context.Context) ([]*taskmodels.Workspace, error) DeleteWorkspace(ctx context.Context, id string) error - ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) DeleteTask(ctx context.Context, id string) error GetLastAgentMessage(ctx context.Context, sessionID string) (string, error) GetLastAgentMessageForTurn(ctx context.Context, turnID string) (string, error) diff --git a/apps/backend/internal/office/service/workspace_deletion.go b/apps/backend/internal/office/service/workspace_deletion.go index 60c0c44ab4..ca513a7a70 100644 --- a/apps/backend/internal/office/service/workspace_deletion.go +++ b/apps/backend/internal/office/service/workspace_deletion.go @@ -33,7 +33,7 @@ func (s *Service) GetWorkspaceDeletionSummary(ctx context.Context, workspaceID s if err != nil { return nil, err } - _, total, err := s.taskWorkspace.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, 1, true, true, false, false) + _, total, err := s.taskWorkspace.ListTasksByWorkspace(ctx, workspaceID, "", "", "", 1, 1, "", true, true, false, false) if err != nil { return nil, fmt.Errorf("count tasks: %w", err) } @@ -113,7 +113,7 @@ func (s *Service) listAllWorkspaceTasks(ctx context.Context, workspaceID string) var all []*taskmodels.Task for page := 1; ; page++ { tasks, total, err := s.taskWorkspace.ListTasksByWorkspace( - ctx, workspaceID, "", "", "", page, workspaceDeletionPageSize, true, true, false, false, + ctx, workspaceID, "", "", "", page, workspaceDeletionPageSize, "", true, true, false, false, ) if err != nil { return nil, fmt.Errorf("list workspace tasks: %w", err) diff --git a/apps/backend/internal/office/service/workspace_deletion_test.go b/apps/backend/internal/office/service/workspace_deletion_test.go index 25a54b634f..2d8c3bca69 100644 --- a/apps/backend/internal/office/service/workspace_deletion_test.go +++ b/apps/backend/internal/office/service/workspace_deletion_test.go @@ -128,6 +128,7 @@ func (f *fakeWorkspaceTaskService) ListTasksByWorkspace( string, int, int, + string, bool, bool, bool, diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 6f1c877f76..357e3eb8cd 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -422,7 +422,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, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { return nil, 0, nil } func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index f9d7a57474..f29eef0858 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -71,7 +71,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, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { return nil, 0, nil } func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index d85cb8ab2d..0edd3cbf88 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -53,6 +53,7 @@ func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { } query := c.Query("query") + sort := usermodels.NormalizeTasksListSort(c.Query("sort")) workflowID := c.Query("workflow_id") repositoryID := c.Query("repository_id") includeArchived := c.Query("include_archived") == queryValueTrue @@ -61,7 +62,7 @@ func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { excludeConfig := c.Query("exclude_config") == queryValueTrue tasks, total, err := h.service.ListTasksByWorkspace( - c.Request.Context(), c.Param("id"), workflowID, repositoryID, query, page, pageSize, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig, + c.Request.Context(), c.Param("id"), workflowID, repositoryID, query, page, pageSize, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig, ) if err != nil { handleNotFound(c, h.logger, err, "tasks not found") diff --git a/apps/backend/internal/task/repository/archive_repository_test.go b/apps/backend/internal/task/repository/archive_repository_test.go index fa4488cf15..7805980b32 100644 --- a/apps/backend/internal/task/repository/archive_repository_test.go +++ b/apps/backend/internal/task/repository/archive_repository_test.go @@ -114,7 +114,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { _ = repo.ArchiveTask(ctx, "task-2") // Without includeArchived: should return only active task - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to list tasks: %v", err) } @@ -126,7 +126,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { } // With includeArchived: should return both tasks - tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 10, true, false, false, false) + tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 10, "", true, false, false, false) if err != nil { t.Fatalf("failed to list tasks with archived: %v", err) } @@ -138,7 +138,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { } // Search with includeArchived=false should filter archived - _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "Task", 1, 10, false, false, false, false) + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "Task", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks: %v", err) } @@ -147,7 +147,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_IncludeArchived(t *testing.T) { } // Search with includeArchived=true should include archived - _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "Task", 1, 10, true, false, false, false) + _, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "Task", 1, 10, "", true, false, false, false) if err != nil { t.Fatalf("failed to search tasks with archived: %v", err) } diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index e8d3736f8d..0a3f20099f 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -33,7 +33,7 @@ type TaskRepository 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, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) ArchiveTask(ctx context.Context, id string) error // ArchiveTaskIfActive is the CAS variant used by office task-handoffs diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 88bd975d00..d1975a721c 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/agentctl/tracing" "github.com/kandev/kandev/internal/db/dialect" "github.com/kandev/kandev/internal/task/models" + usermodels "github.com/kandev/kandev/internal/user/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -22,6 +23,39 @@ import ( // the `tasks` table directly rather than through a join alias. const defaultTaskAlias = "tasks" +type taskScanColumn struct { + name string + selectExpr func(alias string) string +} + +var taskScanColumns = []taskScanColumn{ + {name: "id"}, + {name: "workspace_id"}, + {name: "workflow_id"}, + {name: "workflow_step_id"}, + {name: "title"}, + {name: "description"}, + {name: "state"}, + {name: "priority"}, + {name: "position"}, + {name: "metadata"}, + {name: "is_ephemeral"}, + {name: "parent_id"}, + {name: "archived_at"}, + {name: "created_at"}, + {name: "updated_at"}, + {name: "assignee_agent_profile_id", selectExpr: func(alias string) string { + return runnerProjection(alias) + ` AS assignee_agent_profile_id` + }}, + {name: "origin"}, + {name: "project_id"}, + {name: "labels"}, + {name: "identifier"}, + {name: "is_from_office", selectExpr: func(alias string) string { + return isFromOfficeProjection(alias) + ` AS is_from_office` + }}, +} + // taskSelectColumns returns the column projection (with runner subquery) // for a SELECT against tasks aliased as `alias`. The output column order // matches scanSingleTask / scanTasks. @@ -29,14 +63,27 @@ func taskSelectColumns(alias string) string { if alias == "" { alias = defaultTaskAlias } + return taskScanColumnSQL(alias, true) +} + +func taskProjectedColumns(alias string) string { + if alias == "" { + alias = defaultTaskAlias + } + return taskScanColumnSQL(alias, false) +} + +func taskScanColumnSQL(alias string, useExpressions bool) string { prefix := alias + "." - return prefix + "id, " + prefix + "workspace_id, " + prefix + "workflow_id, " + prefix + "workflow_step_id, " + - prefix + "title, " + prefix + "description, " + prefix + "state, " + prefix + "priority, " + prefix + "position, " + - prefix + "metadata, " + prefix + "is_ephemeral, " + prefix + "parent_id, " + prefix + "archived_at, " + - prefix + "created_at, " + prefix + "updated_at, " + - runnerProjection(alias) + ` AS assignee_agent_profile_id, ` + - prefix + "origin, " + prefix + "project_id, " + prefix + "labels, " + prefix + "identifier, " + - isFromOfficeProjection(alias) + ` AS is_from_office` + cols := make([]string, 0, len(taskScanColumns)) + for _, col := range taskScanColumns { + if useExpressions && col.selectExpr != nil { + cols = append(cols, col.selectExpr(alias)) + continue + } + cols = append(cols, prefix+col.name) + } + return strings.Join(cols, ", ") } // isFromOfficeProjection returns a SQL boolean expression that is true @@ -414,7 +461,7 @@ func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID // If includeArchived is false, archived tasks are excluded // If includeEphemeral is false, ephemeral tasks are excluded // If onlyEphemeral is true, only ephemeral tasks are returned -func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { +func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { ctx, span := tracing.Tracer("kandev-db").Start(ctx, "db.ListTasksByWorkspace") defer span.End() // Calculate offset @@ -422,6 +469,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, work if offset < 0 { offset = 0 } + sort = usermodels.NormalizeTasksListSort(sort) // Build filter conditions filter := "" @@ -447,9 +495,9 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, work var err error if query == "" { - rows, total, err = r.queryAllTasks(ctx, workspaceID, filter, workflowID, repositoryID, pageSize, offset) + rows, total, err = r.queryAllTasks(ctx, workspaceID, filter, workflowID, repositoryID, pageSize, offset, sort) } else { - rows, total, err = r.searchTasks(ctx, workspaceID, query, filter, workflowID, repositoryID, pageSize, offset, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig) + rows, total, err = r.searchTasks(ctx, workspaceID, query, filter, workflowID, repositoryID, pageSize, offset, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig) } if err != nil { @@ -466,7 +514,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID, work } // queryAllTasks fetches all tasks (no search) for a workspace with pagination. -func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, taskFilter, workflowID, repositoryID string, pageSize, offset int) (*sql.Rows, int, error) { +func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, taskFilter, workflowID, repositoryID string, pageSize, offset int, sort string) (*sql.Rows, int, error) { args := []interface{}{workspaceID} if workflowID != "" { taskFilter += " AND workflow_id = ?" @@ -484,12 +532,39 @@ func (r *Repository) queryAllTasks(ctx context.Context, workspaceID, taskFilter, SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.workspace_id = ?`+rewriteFilterForAlias(taskFilter, "t")+` - ORDER BY t.updated_at DESC + ORDER BY `+taskListOrderBy(r.ro.DriverName(), "t", sort)+` LIMIT ? OFFSET ? `), append(append([]interface{}{}, args...), pageSize, offset)...) return rows, total, err } +func taskListOrderBy(driver, alias, sort string) string { + prefix := alias + "." + switch sort { + case usermodels.TasksListSortUpdatedAsc: + return prefix + "updated_at ASC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC" + case usermodels.TasksListSortCreatedDesc: + return prefix + "created_at DESC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC" + case usermodels.TasksListSortCreatedAsc: + return prefix + "created_at ASC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC" + case usermodels.TasksListSortTitleAsc: + return taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "updated_at DESC, " + prefix + "id ASC" + case usermodels.TasksListSortTitleDesc: + return taskTitleOrder(driver, prefix, "DESC") + ", " + prefix + "updated_at DESC, " + prefix + "id ASC" + case usermodels.TasksListSortUpdatedDesc: + fallthrough + default: + return prefix + "updated_at DESC, " + taskTitleOrder(driver, prefix, "ASC") + ", " + prefix + "id ASC" + } +} + +func taskTitleOrder(driver, prefix, direction string) string { + if dialect.IsPostgres(driver) { + return "LOWER(" + prefix + "title) " + direction + ", " + prefix + "title " + direction + } + return prefix + "title COLLATE NOCASE " + direction +} + // rewriteFilterForAlias prefixes bare column references in `filter` with // the given alias. Only used for the small, controlled fragments built // in queryAllTasks and searchTasks. @@ -557,7 +632,7 @@ func isWordByte(b byte) bool { } // searchTasks fetches tasks matching a search query for a workspace with pagination. -func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, filter, workflowID, repositoryID string, pageSize, offset int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) (*sql.Rows, int, error) { +func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, filter, workflowID, repositoryID string, pageSize, offset int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) (*sql.Rows, int, error) { searchPattern := "%" + query + "%" like := dialect.Like(r.ro.DriverName()) @@ -604,26 +679,33 @@ func (r *Repository) searchTasks(ctx context.Context, workspaceID, query, filter return nil, 0, err } - selectQuery := fmt.Sprintf(` - SELECT DISTINCT %s - 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 = ?%s - AND ( - t.title %s ? OR - t.description %s ? OR - r.name %s ? OR - r.local_path %s ? - ) - ORDER BY t.updated_at DESC - LIMIT ? OFFSET ? - `, taskSelectColumns("t"), tFilter, like, like, like, like) + selectQuery := taskSearchSelectQuery(r.ro.DriverName(), tFilter, like, sort) selectArgs := append(append([]interface{}{}, countArgs...), pageSize, offset) rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(selectQuery), selectArgs...) return rows, total, err } +func taskSearchSelectQuery(driver, tFilter, like, sort string) string { + return fmt.Sprintf(` + SELECT %s + FROM ( + SELECT DISTINCT %s + 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 = ?%s + AND ( + t.title %s ? OR + t.description %s ? OR + r.name %s ? OR + r.local_path %s ? + ) + ) task_search + ORDER BY %s + LIMIT ? OFFSET ? + `, taskProjectedColumns("task_search"), taskSelectColumns("t"), tFilter, like, like, like, like, taskListOrderBy(driver, "task_search", sort)) +} + // scanSingleTask scans a single row into a Task. func (r *Repository) scanSingleTask(row *sql.Row) (*models.Task, error) { task := &models.Task{} diff --git a/apps/backend/internal/task/repository/sqlite/task_order_test.go b/apps/backend/internal/task/repository/sqlite/task_order_test.go new file mode 100644 index 0000000000..74b806084b --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_order_test.go @@ -0,0 +1,45 @@ +package sqlite + +import ( + "strings" + "testing" + + "github.com/kandev/kandev/internal/db/dialect" + usermodels "github.com/kandev/kandev/internal/user/models" +) + +func TestTaskListOrderBy_UsesDialectTitleOrdering(t *testing.T) { + sqliteOrder := taskListOrderBy("sqlite3", "t", usermodels.TasksListSortTitleAsc) + if !strings.Contains(sqliteOrder, "t.title COLLATE NOCASE ASC") { + t.Fatalf("sqlite order = %q, want NOCASE title ordering", sqliteOrder) + } + + postgresOrder := taskListOrderBy(dialect.PGX, "t", usermodels.TasksListSortTitleAsc) + if strings.Contains(postgresOrder, "COLLATE NOCASE") { + t.Fatalf("postgres order = %q, must not use SQLite-only COLLATE NOCASE", postgresOrder) + } + if !strings.Contains(postgresOrder, "LOWER(t.title) ASC") { + t.Fatalf("postgres order = %q, want LOWER(title) ordering", postgresOrder) + } +} + +func TestTaskSearchSelectQuery_OrdersOutsideDistinctForPostgres(t *testing.T) { + query := taskSearchSelectQuery(dialect.PGX, "", "ILIKE", usermodels.TasksListSortTitleAsc) + distinctIndex := strings.Index(query, "SELECT DISTINCT") + orderIndex := strings.LastIndex(query, "ORDER BY") + if distinctIndex == -1 { + t.Fatalf("query = %q, want SELECT DISTINCT", query) + } + if orderIndex == -1 { + t.Fatalf("query = %q, want ORDER BY", query) + } + if orderIndex < distinctIndex { + t.Fatalf("query = %q, ORDER BY must be outside SELECT DISTINCT subquery", query) + } + if strings.Contains(query[distinctIndex:orderIndex], "LOWER(") { + t.Fatalf("query = %q, distinct subquery must not order by LOWER(title)", query) + } + if !strings.Contains(query, "ORDER BY LOWER(task_search.title) ASC") { + t.Fatalf("query = %q, want outer Postgres title ordering", query) + } +} diff --git a/apps/backend/internal/task/repository/task_repository_test.go b/apps/backend/internal/task/repository/task_repository_test.go index 2e70988b9b..8bd2da9142 100644 --- a/apps/backend/internal/task/repository/task_repository_test.go +++ b/apps/backend/internal/task/repository/task_repository_test.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository/sqlite" + usermodels "github.com/kandev/kandev/internal/user/models" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -359,7 +360,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, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to list tasks by workspace: %v", err) } @@ -371,7 +372,7 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { } // Test pagination - tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 2, false, false, false, false) + tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 2, "", false, false, false, false) if err != nil { t.Fatalf("failed to list tasks with pagination: %v", err) } @@ -383,7 +384,7 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { } // Test page 2 - tasksPage2, _, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 2, 2, false, false, false, false) + tasksPage2, _, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 2, 2, "", false, false, false, false) if err != nil { t.Fatalf("failed to list tasks page 2: %v", err) } @@ -392,6 +393,43 @@ func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { } } +func TestSQLiteRepository_ListTasksByWorkspace_SortBeforePagination(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "t-c", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "s-1", Title: "Charlie"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "t-a", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "s-1", Title: "Alpha"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "t-b", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "s-1", Title: "Bravo"}) + + page1, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 1, 2, usermodels.TasksListSortTitleAsc, false, false, false, false) + if err != nil { + t.Fatalf("failed to list tasks by workspace: %v", err) + } + if total != 3 { + t.Fatalf("expected total 3 tasks, got %d", total) + } + if len(page1) != 2 { + t.Fatalf("page 1 length = %d, want 2", len(page1)) + } + if got := []string{page1[0].ID, page1[1].ID}; !slices.Equal(got, []string{"t-a", "t-b"}) { + t.Fatalf("page 1 IDs = %v, want [t-a t-b]", got) + } + + page2, _, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "", 2, 2, usermodels.TasksListSortTitleAsc, false, false, false, false) + if err != nil { + t.Fatalf("failed to list tasks page 2: %v", err) + } + if len(page2) != 1 { + t.Fatalf("page 2 length = %d, want 1", len(page2)) + } + if got := []string{page2[0].ID}; !slices.Equal(got, []string{"t-c"}) { + t.Fatalf("page 2 IDs = %v, want [t-c]", got) + } +} + func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { repo, cleanup := createTestSQLiteRepo(t) defer cleanup() @@ -413,7 +451,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, false, false, false, false) + _, totalAuth, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "authentication", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks by title: %v", err) } @@ -422,7 +460,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by description - tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "dark mode", 1, 10, false, false, false, false) + tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "dark mode", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks by description: %v", err) } @@ -434,7 +472,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by repository name - tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "MyProject", 1, 10, false, false, false, false) + tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "MyProject", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks by repository name: %v", err) } @@ -446,7 +484,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search by repository local_path - _, totalPath, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "myproject", 1, 10, false, false, false, false) + _, totalPath, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "myproject", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks by repository path: %v", err) } @@ -455,7 +493,7 @@ func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { } // Test search with no results - tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "nonexistent", 1, 10, false, false, false, false) + tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "nonexistent", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("failed to search tasks with no results: %v", err) } @@ -480,7 +518,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_WorkflowFilter(t *testing.T) { _ = repo.CreateTask(ctx, &models.Task{ID: "t-2", WorkspaceID: "ws-1", WorkflowID: "wf-a", WorkflowStepID: "s-1", Title: "Beta task"}) _ = repo.CreateTask(ctx, &models.Task{ID: "t-3", WorkspaceID: "ws-1", WorkflowID: "wf-b", WorkflowStepID: "s-1", Title: "Gamma task"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "", "", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "", "", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } @@ -513,7 +551,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_RepositoryFilter(t *testing.T) { _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "t-1", RepositoryID: "repo-1"}) _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-2", TaskID: "t-2", RepositoryID: "repo-2"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "repo-1", "", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "repo-1", "", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } @@ -541,7 +579,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_WorkflowAndRepositoryFilter(t *te _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "t-1", RepositoryID: "repo-1"}) _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-3", TaskID: "t-3", RepositoryID: "repo-1"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "repo-1", "", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "repo-1", "", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } @@ -566,7 +604,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_WorkflowFilterWithQuery(t *testin _ = repo.CreateTask(ctx, &models.Task{ID: "t-2", WorkspaceID: "ws-1", WorkflowID: "wf-a", WorkflowStepID: "s-1", Title: "Fix payment bug"}) _ = repo.CreateTask(ctx, &models.Task{ID: "t-3", WorkspaceID: "ws-1", WorkflowID: "wf-b", WorkflowStepID: "s-1", Title: "Fix login bug"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "", "login", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "wf-a", "", "login", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } @@ -591,7 +629,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_RepositoryFilterWithQuery(t *test _ = repo.CreateTask(ctx, &models.Task{ID: "t-2", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "s-1", Title: "Fix auth elsewhere"}) _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-1", TaskID: "t-1", RepositoryID: "repo-1"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "repo-1", "auth", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "repo-1", "auth", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } @@ -620,7 +658,7 @@ func TestSQLiteRepository_ListTasksByWorkspace_DistinctWithMultipleRepos(t *test _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-2", TaskID: "t-1", RepositoryID: "repo-2"}) _ = repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-3", TaskID: "t-2", RepositoryID: "repo-1"}) - tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "task", 1, 10, false, false, false, false) + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", "", "task", 1, 10, "", false, false, false, false) if err != nil { t.Fatalf("ListTasksByWorkspace failed: %v", err) } diff --git a/apps/backend/internal/task/service/handoff_workspace_test.go b/apps/backend/internal/task/service/handoff_workspace_test.go index a375a69f9f..fe26d5409c 100644 --- a/apps/backend/internal/task/service/handoff_workspace_test.go +++ b/apps/backend/internal/task/service/handoff_workspace_test.go @@ -353,7 +353,7 @@ func (r *phase4TaskRepo) ListTasks(context.Context, string) ([]*models.Task, err r.panicNotUsed("ListTasks") return nil, nil } -func (r *phase4TaskRepo) ListTasksByWorkspace(context.Context, string, string, string, string, int, int, bool, bool, bool, bool) ([]*models.Task, int, error) { +func (r *phase4TaskRepo) ListTasksByWorkspace(context.Context, string, string, string, string, int, int, string, bool, bool, bool, bool) ([]*models.Task, int, error) { r.panicNotUsed("ListTasksByWorkspace") return nil, 0, nil } diff --git a/apps/backend/internal/task/service/service_pr_search_test.go b/apps/backend/internal/task/service/service_pr_search_test.go index 23b75239af..4072619755 100644 --- a/apps/backend/internal/task/service/service_pr_search_test.go +++ b/apps/backend/internal/task/service/service_pr_search_test.go @@ -62,7 +62,7 @@ func TestListTasksByWorkspace_PRNumberSurfacesTask(t *testing.T) { ctx := context.Background() // "#1243" — task-pr has no "1243" in its title, only the PR association. - tasks, total, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, true, false, false, false) + tasks, total, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, "", true, false, false, false) if err != nil { t.Fatalf("search: %v", err) } @@ -82,7 +82,7 @@ func TestListTasksByWorkspace_PRNumberDedupes(t *testing.T) { svc.SetPRTaskResolver(&fakePRResolver{byPR: map[int][]string{1243: {"task-title"}}}) ctx := context.Background() - tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "1243", 1, 5, true, false, false, false) + tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "1243", 1, 5, "", true, false, false, false) if err != nil { t.Fatalf("search: %v", err) } @@ -104,7 +104,7 @@ func TestListTasksByWorkspace_NonNumericQuerySkipsResolver(t *testing.T) { svc.SetPRTaskResolver(resolver) ctx := context.Background() - tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "unrelated", 1, 5, true, false, false, false) + tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "unrelated", 1, 5, "", true, false, false, false) if err != nil { t.Fatalf("search: %v", err) } @@ -123,7 +123,7 @@ func TestListTasksByWorkspace_NilResolverNoPanic(t *testing.T) { ctx := context.Background() // No resolver wired — PR-number search must be a safe no-op. - tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, true, false, false, false) + tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, "", true, false, false, false) if err != nil { t.Fatalf("search: %v", err) } @@ -143,7 +143,7 @@ func TestListTasksByWorkspace_PRMatchRespectsArchivedFilter(t *testing.T) { ctx := context.Background() // includeArchived=false → archived PR task excluded. - excluded, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, false, false, false, false) + excluded, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, "", false, false, false, false) if err != nil { t.Fatalf("search excluded: %v", err) } @@ -152,7 +152,7 @@ func TestListTasksByWorkspace_PRMatchRespectsArchivedFilter(t *testing.T) { } // includeArchived=true → archived PR task included. - included, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, true, false, false, false) + included, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", "", "", "#1243", 1, 5, "", true, false, false, false) if err != nil { t.Fatalf("search included: %v", err) } @@ -180,7 +180,7 @@ func TestListTasksByWorkspace_PRMatchSkippedOnLaterPagesAndScopedSearches(t *tes resolver := &fakePRResolver{byPR: map[int][]string{1243: {"task-pr"}}} svc.SetPRTaskResolver(resolver) - tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", tc.workflowID, tc.repoID, "#1243", tc.page, 5, true, false, false, false) + tasks, _, err := svc.ListTasksByWorkspace(ctx, "ws-1", tc.workflowID, tc.repoID, "#1243", tc.page, 5, "", true, false, false, false) if err != nil { t.Fatalf("search: %v", err) } diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index 28b45f7dde..792e6b653c 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -319,7 +319,7 @@ func (s *Service) listAllTasksForWorkspaceDelete(ctx context.Context, workspaceI var all []*models.Task for page := 1; ; page++ { tasks, total, err := s.tasks.ListTasksByWorkspace( - ctx, workspaceID, "", "", "", page, workspaceDeletePageSize, true, true, false, false, + ctx, workspaceID, "", "", "", page, workspaceDeletePageSize, "", true, true, false, false, ) if err != nil { return nil, fmt.Errorf("list workspace tasks: %w", err) diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 3628813240..932bd69f12 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -1545,8 +1545,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. // workflowID and repositoryID, when non-empty, further restrict results to that workflow/repository. -func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { - tasks, total, err := s.tasks.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, query, page, pageSize, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig) +func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*models.Task, int, error) { + tasks, total, err := s.tasks.ListTasksByWorkspace(ctx, workspaceID, workflowID, repositoryID, query, page, pageSize, sort, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig) if err != nil { return nil, 0, err } diff --git a/apps/backend/internal/user/controller/controller.go b/apps/backend/internal/user/controller/controller.go index fbf93da9c7..ba6400b1e6 100644 --- a/apps/backend/internal/user/controller/controller.go +++ b/apps/backend/internal/user/controller/controller.go @@ -48,6 +48,8 @@ func (c *Controller) UpdateUserSettings(ctx context.Context, req dto.UpdateUserS KanbanViewMode: req.KanbanViewMode, WorkflowFilterID: req.WorkflowFilterID, RepositoryIDs: req.RepositoryIDs, + TasksListSort: req.TasksListSort, + TasksListGroup: req.TasksListGroup, InitialSetupComplete: req.InitialSetupComplete, PreferredShell: req.PreferredShell, DefaultEditorID: req.DefaultEditorID, diff --git a/apps/backend/internal/user/dto/dto.go b/apps/backend/internal/user/dto/dto.go index baaa3ad816..af6a323063 100644 --- a/apps/backend/internal/user/dto/dto.go +++ b/apps/backend/internal/user/dto/dto.go @@ -20,6 +20,8 @@ type UserSettingsDTO struct { KanbanViewMode string `json:"kanban_view_mode"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` @@ -74,6 +76,8 @@ type UpdateUserSettingsRequest struct { KanbanViewMode *string `json:"kanban_view_mode,omitempty"` WorkflowFilterID *string `json:"workflow_filter_id,omitempty"` RepositoryIDs *[]string `json:"repository_ids,omitempty"` + TasksListSort *string `json:"tasks_list_sort,omitempty"` + TasksListGroup *string `json:"tasks_list_group,omitempty"` InitialSetupComplete *bool `json:"initial_setup_complete,omitempty"` PreferredShell *string `json:"preferred_shell,omitempty"` DefaultEditorID *string `json:"default_editor_id,omitempty"` @@ -184,6 +188,8 @@ func FromUserSettings(settings *models.UserSettings) UserSettingsDTO { KanbanViewMode: settings.KanbanViewMode, WorkflowFilterID: settings.WorkflowFilterID, RepositoryIDs: settings.RepositoryIDs, + TasksListSort: settings.TasksListSort, + TasksListGroup: settings.TasksListGroup, InitialSetupComplete: settings.InitialSetupComplete, PreferredShell: settings.PreferredShell, DefaultEditorID: settings.DefaultEditorID, diff --git a/apps/backend/internal/user/models/models.go b/apps/backend/internal/user/models/models.go index 440f60b5a5..c828198e33 100644 --- a/apps/backend/internal/user/models/models.go +++ b/apps/backend/internal/user/models/models.go @@ -18,6 +18,8 @@ type UserSettings struct { KanbanViewMode string `json:"kanban_view_mode"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` diff --git a/apps/backend/internal/user/models/tasks_list_preferences.go b/apps/backend/internal/user/models/tasks_list_preferences.go new file mode 100644 index 0000000000..05ebe72417 --- /dev/null +++ b/apps/backend/internal/user/models/tasks_list_preferences.go @@ -0,0 +1,71 @@ +package models + +import ( + "slices" + "strings" +) + +const ( + TasksListSortUpdatedDesc = "updated_desc" + TasksListSortUpdatedAsc = "updated_asc" + TasksListSortCreatedDesc = "created_desc" + TasksListSortCreatedAsc = "created_asc" + TasksListSortTitleAsc = "title_asc" + TasksListSortTitleDesc = "title_desc" + TasksListSortDefault = TasksListSortUpdatedDesc + + TasksListGroupState = "state" + TasksListGroupWorkflow = "workflow" + TasksListGroupRepository = "repository" + TasksListGroupNone = "none" + TasksListGroupDefault = TasksListGroupState +) + +var ( + tasksListSortValues = []string{ + TasksListSortUpdatedDesc, + TasksListSortUpdatedAsc, + TasksListSortCreatedDesc, + TasksListSortCreatedAsc, + TasksListSortTitleAsc, + TasksListSortTitleDesc, + } + tasksListGroupValues = []string{ + TasksListGroupState, + TasksListGroupWorkflow, + TasksListGroupRepository, + TasksListGroupNone, + } +) + +func TasksListSortValues() []string { + return append([]string(nil), tasksListSortValues...) +} + +func TasksListGroupValues() []string { + return append([]string(nil), tasksListGroupValues...) +} + +func IsValidTasksListSort(value string) bool { + return slices.Contains(tasksListSortValues, strings.TrimSpace(value)) +} + +func IsValidTasksListGroup(value string) bool { + return slices.Contains(tasksListGroupValues, strings.TrimSpace(value)) +} + +func NormalizeTasksListSort(value string) string { + value = strings.TrimSpace(value) + if IsValidTasksListSort(value) { + return value + } + return TasksListSortDefault +} + +func NormalizeTasksListGroup(value string) string { + value = strings.TrimSpace(value) + if IsValidTasksListGroup(value) { + return value + } + return TasksListGroupDefault +} diff --git a/apps/backend/internal/user/service/service.go b/apps/backend/internal/user/service/service.go index e54e117701..61d42bcc3e 100644 --- a/apps/backend/internal/user/service/service.go +++ b/apps/backend/internal/user/service/service.go @@ -39,6 +39,8 @@ type UpdateUserSettingsRequest struct { KanbanViewMode *string WorkflowFilterID *string RepositoryIDs *[]string + TasksListSort *string + TasksListGroup *string InitialSetupComplete *bool PreferredShell *string DefaultEditorID *string @@ -193,6 +195,9 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe if req.RepositoryIDs != nil { settings.RepositoryIDs = *req.RepositoryIDs } + if err := applyTasksListPreferences(settings, req.TasksListSort, req.TasksListGroup); err != nil { + return err + } if req.InitialSetupComplete != nil { settings.InitialSetupComplete = *req.InitialSetupComplete } @@ -248,6 +253,30 @@ func applyBasicSettings(settings *models.UserSettings, req *UpdateUserSettingsRe return nil } +func applyTasksListPreferences(settings *models.UserSettings, sortValue, groupValue *string) error { + if sortValue != nil { + v := strings.TrimSpace(*sortValue) + if v == "" { + v = models.TasksListSortDefault + } + if !models.IsValidTasksListSort(v) { + return fmt.Errorf("tasks_list_sort must be one of %s", strings.Join(models.TasksListSortValues(), ", ")) + } + settings.TasksListSort = v + } + if groupValue != nil { + v := strings.TrimSpace(*groupValue) + if v == "" { + v = models.TasksListGroupDefault + } + if !models.IsValidTasksListGroup(v) { + return fmt.Errorf("tasks_list_group must be one of %s", strings.Join(models.TasksListGroupValues(), ", ")) + } + settings.TasksListGroup = v + } + return nil +} + func applyTerminalLinkBehavior(settings *models.UserSettings, value *string) error { if value == nil { return nil @@ -505,6 +534,8 @@ func (s *Service) publishUserSettingsEvent(ctx context.Context, settings *models "kanban_view_mode": settings.KanbanViewMode, "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, + "tasks_list_sort": settings.TasksListSort, + "tasks_list_group": settings.TasksListGroup, "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, diff --git a/apps/backend/internal/user/service/service_test.go b/apps/backend/internal/user/service/service_test.go index 20200c220b..93c51b0c01 100644 --- a/apps/backend/internal/user/service/service_test.go +++ b/apps/backend/internal/user/service/service_test.go @@ -104,6 +104,41 @@ func TestApplyBasicSettings_ReleaseNotes(t *testing.T) { }) } +func TestApplyBasicSettings_TasksListPreferences(t *testing.T) { + t.Run("sets valid sort and group", func(t *testing.T) { + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{ + TasksListSort: ptr("title_asc"), + TasksListGroup: ptr("repository"), + } + if err := applyBasicSettings(settings, req); err != nil { + t.Fatalf("unexpected error: %v", err) + } + if settings.TasksListSort != "title_asc" { + t.Fatalf("TasksListSort = %q, want title_asc", settings.TasksListSort) + } + if settings.TasksListGroup != "repository" { + t.Fatalf("TasksListGroup = %q, want repository", settings.TasksListGroup) + } + }) + + t.Run("rejects invalid sort", func(t *testing.T) { + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{TasksListSort: ptr("priority_desc")} + if err := applyBasicSettings(settings, req); err == nil { + t.Fatal("expected invalid sort error") + } + }) + + t.Run("rejects invalid group", func(t *testing.T) { + settings := &models.UserSettings{} + req := &UpdateUserSettingsRequest{TasksListGroup: ptr("assignee")} + if err := applyBasicSettings(settings, req); err == nil { + t.Fatal("expected invalid group error") + } + }) +} + func TestApplyBasicSettings_TerminalFontFamily(t *testing.T) { t.Run("nil leaves settings unchanged", func(t *testing.T) { settings := &models.UserSettings{TerminalFontFamily: "Fira Code"} diff --git a/apps/backend/internal/user/store/sqlite.go b/apps/backend/internal/user/store/sqlite.go index 8e8f3ff867..149532f59f 100644 --- a/apps/backend/internal/user/store/sqlite.go +++ b/apps/backend/internal/user/store/sqlite.go @@ -329,6 +329,8 @@ func marshalUserSettingsPayload(settings *models.UserSettings) ([]byte, error) { "kanban_view_mode": settings.KanbanViewMode, "workflow_filter_id": settings.WorkflowFilterID, "repository_ids": settings.RepositoryIDs, + "tasks_list_sort": models.NormalizeTasksListSort(settings.TasksListSort), + "tasks_list_group": models.NormalizeTasksListGroup(settings.TasksListGroup), "initial_setup_complete": settings.InitialSetupComplete, "preferred_shell": settings.PreferredShell, "default_editor_id": settings.DefaultEditorID, @@ -447,6 +449,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.UserID = userID if settingsRaw == "" || settingsRaw == "{}" { settings.RepositoryIDs = []string{} + settings.TasksListSort = models.TasksListSortDefault + settings.TasksListGroup = models.TasksListGroupDefault settings.ShowReleaseNotification = true settings.ReviewAutoMarkOnScroll = true settings.ChatSubmitKey = "cmd_enter" @@ -463,6 +467,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin KanbanViewMode string `json:"kanban_view_mode"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` InitialSetupComplete bool `json:"initial_setup_complete"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` @@ -502,6 +508,8 @@ func scanUserSettings(scanner interface{ Scan(dest ...any) error }, userID strin settings.KanbanViewMode = payload.KanbanViewMode settings.WorkflowFilterID = payload.WorkflowFilterID settings.RepositoryIDs = payload.RepositoryIDs + settings.TasksListSort = models.NormalizeTasksListSort(payload.TasksListSort) + settings.TasksListGroup = models.NormalizeTasksListGroup(payload.TasksListGroup) settings.InitialSetupComplete = payload.InitialSetupComplete settings.PreferredShell = payload.PreferredShell settings.DefaultEditorID = payload.DefaultEditorID diff --git a/apps/backend/pkg/api/v1/user.go b/apps/backend/pkg/api/v1/user.go index 9fd42cfb4d..a6d689d809 100644 --- a/apps/backend/pkg/api/v1/user.go +++ b/apps/backend/pkg/api/v1/user.go @@ -16,6 +16,8 @@ type UserSettings struct { KanbanViewMode string `json:"kanban_view_mode"` WorkflowFilterID string `json:"workflow_filter_id"` RepositoryIDs []string `json:"repository_ids"` + TasksListSort string `json:"tasks_list_sort"` + TasksListGroup string `json:"tasks_list_group"` PreferredShell string `json:"preferred_shell"` DefaultEditorID string `json:"default_editor_id"` UpdatedAt time.Time `json:"updated_at"` diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 46790af4c1..21774fe250 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -115,19 +115,21 @@ type ListTasksOptions = { query?: string; workflowId?: string | null; repositoryId?: string | null; + sort?: string; }; export async function listTasksByWorkspaceAction( workspaceId: string, options: ListTasksOptions = {}, ): Promise { - const { page = 1, pageSize = 50, query, workflowId, repositoryId } = options; + const { page = 1, pageSize = 50, query, workflowId, repositoryId, sort } = options; const url = new URL(`${apiBaseUrl}/api/v1/workspaces/${workspaceId}/tasks`); url.searchParams.set("page", String(page)); url.searchParams.set("page_size", String(pageSize)); if (query) url.searchParams.set("query", query); if (workflowId) url.searchParams.set("workflow_id", workflowId); if (repositoryId) url.searchParams.set("repository_id", repositoryId); + if (sort) url.searchParams.set("sort", sort); return fetchJson(url.toString()); } diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx index 45c8ce2e9d..87f6bb5a12 100644 --- a/apps/web/app/tasks/page.tsx +++ b/apps/web/app/tasks/page.tsx @@ -3,21 +3,19 @@ import { listWorkflowsAction, listTasksByWorkspaceAction, listRepositoriesAction, - listWorkspaceWorkflowStepsAction, } from "@/app/actions/workspaces"; import { fetchUserSettings } from "@/lib/api"; import { StateHydrator } from "@/components/state-hydrator"; import { mapUserSettingsResponse } from "@/lib/ssr/user-settings"; import { resolveDesiredWorkflowId } from "@/lib/kanban/resolve-workflow"; import { TasksPageClient } from "./tasks-page-client"; -import type { - Workflow, - Task, - WorkflowStep, - Repository, - Workspace, - UserSettingsResponse, -} from "@/lib/types/http"; +import { + parseTasksListGroup, + parseTasksListSort, + type TasksListGroup, + type TasksListSort, +} from "@/lib/tasks/tasks-list-options"; +import type { Workflow, Task, Repository, Workspace, UserSettingsResponse } from "@/lib/types/http"; import type { AppState } from "@/lib/state/store"; type WorkspaceData = { @@ -25,30 +23,53 @@ type WorkspaceData = { repositories: Repository[]; tasks: Task[]; total: number; - steps: WorkflowStep[]; activeWorkflowId: string | null; }; +type TasksListPreferences = { + sort: TasksListSort; + group: TasksListGroup; +}; + +function resolveTasksListPreferences( + sortParam: string | undefined, + groupParam: string | undefined, + settingsResponse: UserSettingsResponse | null, +): TasksListPreferences { + return { + sort: parseTasksListSort(sortParam ?? settingsResponse?.settings?.tasks_list_sort), + group: parseTasksListGroup(groupParam ?? settingsResponse?.settings?.tasks_list_group), + }; +} + +function resolveTasksPageWorkspaceId( + workspaceParam: string | undefined, + settingsResponse: UserSettingsResponse | null, + workspaces: Workspace[], +): string | undefined { + if (workspaceParam) return workspaceParam; + return settingsResponse?.settings?.workspace_id || workspaces[0]?.id; +} + async function fetchWorkspaceData( workspaceId: string, settingsResponse: UserSettingsResponse | null, + tasksListSort: TasksListSort, ): Promise { const savedWorkflowId = settingsResponse?.settings?.workflow_filter_id ?? null; const savedRepositoryId = settingsResponse?.settings?.repository_ids?.[0] ?? null; - const [workflowsResponse, repositoriesResponse, tasksResponse, stepsResponse] = await Promise.all( - [ - listWorkflowsAction(workspaceId), - listRepositoriesAction(workspaceId), - listTasksByWorkspaceAction(workspaceId, { - page: 1, - pageSize: 25, - workflowId: savedWorkflowId, - repositoryId: savedRepositoryId, - }), - listWorkspaceWorkflowStepsAction(workspaceId), - ], - ); + const [workflowsResponse, repositoriesResponse, tasksResponse] = await Promise.all([ + listWorkflowsAction(workspaceId), + listRepositoriesAction(workspaceId), + listTasksByWorkspaceAction(workspaceId, { + page: 1, + pageSize: 25, + workflowId: savedWorkflowId, + repositoryId: savedRepositoryId, + sort: tasksListSort, + }), + ]); const workflows = workflowsResponse.workflows; // Preserve "All Workflows" (null/empty saved filter) instead of falling back @@ -64,7 +85,6 @@ async function fetchWorkspaceData( repositories: repositoriesResponse.repositories, tasks: tasksResponse.tasks, total: tasksResponse.total, - steps: stepsResponse.steps, activeWorkflowId, }; } @@ -72,19 +92,20 @@ async function fetchWorkspaceData( export default async function TasksPage({ searchParams, }: { - searchParams: Promise<{ workspace?: string }>; + searchParams: Promise<{ workspace?: string; sort?: string; group?: string }>; }) { - const { workspace: workspaceParam } = await searchParams; + const { workspace: workspaceParam, sort: sortParam, group: groupParam } = await searchParams; let workspaces: Workspace[] = []; let workflows: Workflow[] = []; - let steps: WorkflowStep[] = []; let repositories: Repository[] = []; let tasks: Task[] = []; let total = 0; let workspaceId = workspaceParam; let userSettingsResponse: UserSettingsResponse | null = null; let activeWorkflowId: string | null = null; + let tasksListSort: TasksListSort = parseTasksListSort(null); + let tasksListGroup: TasksListGroup = parseTasksListGroup(null); try { const [workspacesResponse, settingsResponse] = await Promise.all([ @@ -93,20 +114,20 @@ export default async function TasksPage({ ]); workspaces = workspacesResponse.workspaces; userSettingsResponse = settingsResponse; + const preferences = resolveTasksListPreferences(sortParam, groupParam, settingsResponse); + tasksListSort = preferences.sort; + tasksListGroup = preferences.group; // Use workspace from user settings or URL param or first workspace - if (!workspaceId) { - workspaceId = settingsResponse?.settings?.workspace_id || workspaces[0]?.id; - } + workspaceId = resolveTasksPageWorkspaceId(workspaceId, settingsResponse, workspaces); if (workspaceId) { // Fetch all data in parallel; resolve active filters so the server applies them - const data = await fetchWorkspaceData(workspaceId, settingsResponse); + const data = await fetchWorkspaceData(workspaceId, settingsResponse, tasksListSort); workflows = data.workflows; repositories = data.repositories; tasks = data.tasks; total = data.total; - steps = data.steps; activeWorkflowId = data.activeWorkflowId; } } catch (error) { @@ -131,7 +152,12 @@ export default async function TasksPage({ })), activeId: activeWorkflowId, }, - userSettings: { ...mappedUserSettings, workspaceId: workspaceId ?? null }, + userSettings: { + ...mappedUserSettings, + workspaceId: workspaceId ?? null, + tasksListSort, + tasksListGroup, + }, ...(workspaceId ? { repositories: { @@ -150,10 +176,11 @@ export default async function TasksPage({ workspaces={workspaces} initialWorkspaceId={workspaceId} initialWorkflows={workflows} - initialSteps={steps} initialRepositories={repositories} initialTasks={tasks} initialTotal={total} + initialSort={tasksListSort} + initialGroup={tasksListGroup} /> ); diff --git a/apps/web/app/tasks/tasks-list-view.tsx b/apps/web/app/tasks/tasks-list-view.tsx new file mode 100644 index 0000000000..d83c9e5a76 --- /dev/null +++ b/apps/web/app/tasks/tasks-list-view.tsx @@ -0,0 +1,546 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { PaginationState } from "@tanstack/react-table"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Checkbox } from "@kandev/ui/checkbox"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { IconArchive, IconLoader, IconTrash } from "@tabler/icons-react"; +import { TaskArchiveConfirmDialog } from "@/components/task/task-archive-confirm-dialog"; +import { TaskDeleteConfirmDialog } from "@/components/task/task-delete-confirm-dialog"; +import { primaryTaskRepository, type Repository, type Task, type Workflow } from "@/lib/types/http"; +import { formatTaskStateLabel } from "@/lib/ui/state-labels"; +import { getTaskStateIcon } from "@/lib/ui/state-icons"; +import { formatRelativeTime } from "@/lib/utils"; +import { TasksPagination } from "./tasks-pagination"; +import { + TASKS_LIST_GROUP_OPTIONS, + TASKS_LIST_SORT_OPTIONS, + TASK_STATE_ORDER, + type TasksListGroup, + type TasksListSort, +} from "@/lib/tasks/tasks-list-options"; + +export type TasksListViewProps = { + total: number; + showArchived: boolean; + setShowArchived: (show: boolean) => void; + tasksListSort: TasksListSort; + onTasksListSortChange: (sort: TasksListSort) => void; + tasksListGroup: TasksListGroup; + onTasksListGroupChange: (group: TasksListGroup) => void; + tasks: Task[]; + workflows: Workflow[]; + repositories: Repository[]; + pageCount: number; + pagination: PaginationState; + setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void; + isLoading: boolean; + handleRowClick: (task: Task) => void; + deletingTaskId: string | null; + handleArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise; + handleDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; +}; + +export function TasksListView({ + total, + showArchived, + setShowArchived, + tasksListSort, + onTasksListSortChange, + tasksListGroup, + onTasksListGroupChange, + tasks, + workflows, + repositories, + pageCount, + pagination, + setPagination, + isLoading, + handleRowClick, + deletingTaskId, + handleArchive, + handleDelete, +}: TasksListViewProps) { + return ( +
+
+ + + +
+
+ ); +} + +function TasksListControls({ + showArchived, + onShowArchivedChange, + tasksListSort, + onTasksListSortChange, + tasksListGroup, + onTasksListGroupChange, +}: { + showArchived: boolean; + onShowArchivedChange: (show: boolean) => void; + tasksListSort: TasksListSort; + onTasksListSortChange: (sort: TasksListSort) => void; + tasksListGroup: TasksListGroup; + onTasksListGroupChange: (group: TasksListGroup) => void; +}) { + return ( +
+ onTasksListSortChange(value as TasksListSort)} + testId="tasks-list-sort" + /> + onTasksListGroupChange(value as TasksListGroup)} + testId="tasks-list-group" + /> + +
+ ); +} + +function ListOptionSelect({ + label, + value, + options, + onChange, + testId, +}: { + label: string; + value: T; + options: ReadonlyArray<{ readonly value: T; readonly label: string }>; + onChange: (value: T) => void; + testId: string; +}) { + return ( +
+ {label} + +
+ ); +} + +type TaskTreeNode = { + task: Task; + children: TaskTreeNode[]; + level: number; +}; + +type TaskListSection = { + key: string; + title: string | null; + nodes: TaskTreeNode[]; +}; + +function TaskRows({ + tasks, + workflows, + repositories, + tasksListGroup, + isLoading, + deletingTaskId, + onArchive, + onDelete, + onRowClick, +}: { + tasks: Task[]; + workflows: Workflow[]; + repositories: Repository[]; + tasksListGroup: TasksListGroup; + isLoading: boolean; + deletingTaskId: string | null; + onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onRowClick: (task: Task) => void; +}) { + const workflowMap = useMemo(() => new Map(workflows.map((w) => [w.id, w.name])), [workflows]); + const repoMap = useMemo(() => new Map(repositories.map((r) => [r.id, r.name])), [repositories]); + const sections = useMemo( + () => buildTaskSections(tasks, { groupBy: tasksListGroup, workflowMap, repoMap }), + [repoMap, tasks, tasksListGroup, workflowMap], + ); + + if (isLoading) { + return ( +
+ Loading tasks... +
+ ); + } + if (tasks.length === 0) { + return ( +
+ No tasks found. +
+ ); + } + + return ( +
+ {sections.map((section) => ( + + ))} +
+ ); +} + +function buildTaskSections( + tasks: Task[], + { + groupBy, + workflowMap, + repoMap, + }: { + groupBy: TasksListGroup; + workflowMap: Map; + repoMap: Map; + }, +): TaskListSection[] { + const roots = buildTaskTree(tasks); + if (groupBy === "none") { + return [{ key: "all", title: null, nodes: roots }]; + } + + const sections = new Map(); + for (const node of roots) { + const { key, title } = groupForTask(node.task, groupBy, workflowMap, repoMap); + const section = sections.get(key) ?? { key, title, nodes: [] }; + section.nodes.push(node); + sections.set(key, section); + } + + return Array.from(sections.values()).sort((a, b) => compareSection(a, b, groupBy)); +} + +function buildTaskTree(tasks: Task[]): TaskTreeNode[] { + const childrenByParent = new Map(); + const taskIds = new Set(tasks.map((task) => task.id)); + const roots: Task[] = []; + + for (const task of tasks) { + if (task.parent_id && taskIds.has(task.parent_id)) { + const siblings = childrenByParent.get(task.parent_id) ?? []; + siblings.push(task); + childrenByParent.set(task.parent_id, siblings); + } else { + roots.push(task); + } + } + + const visited = new Set(); + + const buildNode = (task: Task, level: number): TaskTreeNode | null => { + if (visited.has(task.id)) return null; + visited.add(task.id); + return { + task, + level, + children: (childrenByParent.get(task.id) ?? []) + .map((child) => buildNode(child, level + 1)) + .filter((node): node is TaskTreeNode => node !== null), + }; + }; + + const nodes = roots + .map((task) => buildNode(task, 0)) + .filter((node): node is TaskTreeNode => node !== null); + for (const task of tasks) { + const node = buildNode(task, 0); + if (node) nodes.push(node); + } + + return nodes; +} + +function groupForTask( + task: Task, + groupBy: TasksListGroup, + workflowMap: Map, + repoMap: Map, +) { + if (groupBy === "workflow") { + const title = workflowMap.get(task.workflow_id); + if (!title) return { key: "workflow:none", title: "No workflow" }; + return { key: `workflow:${task.workflow_id || "none"}`, title }; + } + if (groupBy === "repository") { + const primaryRepo = primaryTaskRepository(task.repositories); + if (!primaryRepo) return { key: "repository:none", title: "No repository" }; + const repoId = primaryRepo?.repository_id ?? "none"; + const title = repoMap.get(repoId); + if (!title) return { key: "repository:none", title: "No repository" }; + return { key: `repository:${repoId}`, title }; + } + const title = formatTaskStateLabel(task.state); + return { key: `state:${task.state}`, title }; +} + +function compareSection(a: TaskListSection, b: TaskListSection, groupBy: TasksListGroup): number { + if (groupBy === "state") { + const aIndex = TASK_STATE_ORDER.indexOf(a.key.replace("state:", "") as Task["state"]); + const bIndex = TASK_STATE_ORDER.indexOf(b.key.replace("state:", "") as Task["state"]); + return ( + (aIndex === -1 ? Number.MAX_SAFE_INTEGER : aIndex) - + (bIndex === -1 ? Number.MAX_SAFE_INTEGER : bIndex) + ); + } + return (a.title ?? "").localeCompare(b.title ?? "", undefined, { sensitivity: "base" }); +} + +function flattenTaskTree(nodes: TaskTreeNode[]): TaskTreeNode[] { + return nodes.flatMap((node) => [node, ...flattenTaskTree(node.children)]); +} + +function TaskListRow({ + task, + level, + deletingTaskId, + onArchive, + onDelete, + onRowClick, +}: { + task: Task; + level: number; + deletingTaskId: string | null; + onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onRowClick: (task: Task) => void; +}) { + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [showArchiveConfirm, setShowArchiveConfirm] = useState(false); + const isDeleting = deletingTaskId === task.id; + const isArchived = !!task.archived_at; + + return ( +
onRowClick(task)} + onKeyDown={(event) => { + if (event.target !== event.currentTarget) return; + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onRowClick(task); + } + }} + > +
+ {getTaskStateIcon(task.state, "h-4 w-4 shrink-0")} + + {task.title} + + {isArchived && ( + + Archived + + )} +
+
+ + {formatRelativeTime(task.updated_at)} + + +
+
+ ); +} + +function TaskListSectionView({ + section, + deletingTaskId, + onArchive, + onDelete, + onRowClick, +}: { + section: TaskListSection; + deletingTaskId: string | null; + onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onRowClick: (task: Task) => void; +}) { + const rows = flattenTaskTree(section.nodes); + return ( +
+ {section.title && ( +
+ {section.title} + {rows.length} +
+ )} +
+ {rows.map(({ task, level }) => ( + + ))} +
+
+ ); +} + +function TaskRowActions({ + task, + isArchived, + isDeleting, + showDeleteConfirm, + showArchiveConfirm, + onDeleteOpenChange, + onArchiveOpenChange, + onArchive, + onDelete, +}: { + task: Task; + isArchived: boolean; + isDeleting: boolean; + showDeleteConfirm: boolean; + showArchiveConfirm: boolean; + onDeleteOpenChange: (open: boolean) => void; + onArchiveOpenChange: (open: boolean) => void; + onArchive: (taskId: string, opts?: { cascade?: boolean }) => Promise; + onDelete: (taskId: string, opts?: { cascade?: boolean }) => Promise; +}) { + return ( +
event.stopPropagation()}> + {!isArchived && ( + + + + + Archive + + )} + + + + + + + Delete + + onDelete(task.id, { cascade })} + /> + onArchive(task.id, { cascade })} + /> +
+ ); +} diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx index 1f7001aa3e..cad2bc0e42 100644 --- a/apps/web/app/tasks/tasks-page-client.tsx +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -1,33 +1,38 @@ "use client"; import { useState, useCallback, useMemo, useEffect, useRef } from "react"; -import { useRouter } from "@/lib/routing/client-router"; +import { useRouter, useSearchParams } from "@/lib/routing/client-router"; import type { PaginationState } from "@tanstack/react-table"; -import { DataTable } from "@/components/ui/data-table"; -import { getColumns } from "./columns"; -import { archiveTask, deleteTask, listTasksByWorkspace } from "@/lib/api"; -import { TaskCreateDialog } from "@/components/task-create-dialog"; +import { archiveTask, deleteTask, listTasksByWorkspace, updateUserSettings } from "@/lib/api"; import { KanbanHeader } from "@/components/kanban/kanban-header"; import { MobileSearchBar } from "@/components/kanban/mobile-search-bar"; -import { Checkbox } from "@kandev/ui/checkbox"; -import { Label } from "@kandev/ui/label"; -import type { Task, Workspace, Workflow, WorkflowStep, Repository } from "@/lib/types/http"; +import type { Task, Workspace, Workflow, Repository } from "@/lib/types/http"; import { useToast } from "@/components/toast-provider"; -import { useAppStore } from "@/components/state-provider"; -import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings"; import { useDebounce } from "@/hooks/use-debounce"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { linkToTask } from "@/lib/links"; import { shouldSkipInitialTasksFetch } from "./tasks-page-fetch-policy"; +import { TasksListView } from "./tasks-list-view"; +import { + parseTasksListGroup, + parseTasksListSort, + sortTasksForList, + type TasksListGroup, + type TasksListSort, +} from "@/lib/tasks/tasks-list-options"; interface TasksPageClientProps { workspaces: Workspace[]; initialWorkspaceId?: string; initialWorkflows: Workflow[]; - initialSteps: WorkflowStep[]; initialRepositories: Repository[]; initialTasks: Task[]; initialTotal: number; initialDataLoaded?: boolean; + initialSort: TasksListSort; + initialGroup: TasksListGroup; } type UseTaskOperationsParams = { @@ -37,10 +42,33 @@ type UseTaskOperationsParams = { pagination: PaginationState; debouncedQuery: string; showArchived: boolean; + tasksListSort: TasksListSort; setTasks: (tasks: Task[]) => void; setTotal: (total: number) => void; }; +function useLatestWorkspaceRequest(activeWorkspaceId: string | null) { + const latestFetchRef = useRef({ seq: 0, workspaceId: activeWorkspaceId }); + + useEffect(() => { + latestFetchRef.current.workspaceId = activeWorkspaceId; + }, [activeWorkspaceId]); + + const beginRequest = useCallback((workspaceId: string) => { + const seq = latestFetchRef.current.seq + 1; + latestFetchRef.current = { seq, workspaceId }; + return seq; + }, []); + + const isCurrentRequest = useCallback( + (seq: number, workspaceId: string) => + latestFetchRef.current.seq === seq && latestFetchRef.current.workspaceId === workspaceId, + [], + ); + + return { beginRequest, isCurrentRequest }; +} + function useTaskOperations({ activeWorkspaceId, activeWorkflowId, @@ -48,15 +76,19 @@ function useTaskOperations({ pagination, debouncedQuery, showArchived, + tasksListSort, setTasks, setTotal, }: UseTaskOperationsParams) { const { toast } = useToast(); const [isLoading, setIsLoading] = useState(false); const [deletingTaskId, setDeletingTaskId] = useState(null); + const { beginRequest, isCurrentRequest } = useLatestWorkspaceRequest(activeWorkspaceId); const fetchTasks = useCallback(async () => { if (!activeWorkspaceId) return; + const requestSeq = beginRequest(activeWorkspaceId); + const shouldCommit = () => isCurrentRequest(requestSeq, activeWorkspaceId); setIsLoading(true); try { const result = await listTasksByWorkspace(activeWorkspaceId, { @@ -66,17 +98,20 @@ function useTaskOperations({ includeArchived: showArchived, workflowId: activeWorkflowId, repositoryId: selectedRepositoryId, + sort: tasksListSort, }); + if (!shouldCommit()) return; setTasks(result.tasks); setTotal(result.total); } catch (err) { + if (!shouldCommit()) return; toast({ title: "Failed to load tasks", description: err instanceof Error ? err.message : "Unknown error", variant: "error", }); } finally { - setIsLoading(false); + if (shouldCommit()) setIsLoading(false); } }, [ activeWorkspaceId, @@ -86,6 +121,9 @@ function useTaskOperations({ pagination.pageSize, debouncedQuery, showArchived, + tasksListSort, + beginRequest, + isCurrentRequest, toast, setTasks, setTotal, @@ -130,142 +168,50 @@ function useTaskOperations({ return { isLoading, deletingTaskId, fetchTasks, handleArchive, handleDelete }; } -type TasksPageBodyProps = { - total: number; - showArchived: boolean; - setShowArchived: (show: boolean) => void; - columns: ReturnType; - tasks: Task[]; - pageCount: number; - pagination: PaginationState; - setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void; - isLoading: boolean; - handleRowClick: (task: Task) => void; -}; - -function TasksPageBody({ - total, - showArchived, - setShowArchived, - columns, - tasks, - pageCount, - pagination, - setPagination, - isLoading, - handleRowClick, -}: TasksPageBodyProps) { - return ( -
-
-
-
-

All Tasks

-

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

-
-
- setShowArchived(checked === true)} - /> - -
-
- -
-
- ); -} - -type TaskCreateDialogMountProps = { - activeWorkspaceId: string | null; - defaultWorkflow: Workflow | undefined; - defaultStep: WorkflowStep | undefined; - createDialogOpen: boolean; - setCreateDialogOpen: (open: boolean) => void; - steps: WorkflowStep[]; - fetchTasks: () => void; -}; - -function TaskCreateDialogMount({ - activeWorkspaceId, - defaultWorkflow, - defaultStep, - createDialogOpen, - setCreateDialogOpen, - steps, - fetchTasks, -}: TaskCreateDialogMountProps) { - if (!activeWorkspaceId || !defaultWorkflow || !defaultStep) return null; - return ( - s.workflow_id === defaultWorkflow.id) - .map((s) => ({ id: s.id, title: s.name, events: s.events }))} - onSuccess={() => { - setCreateDialogOpen(false); - fetchTasks(); - }} - /> - ); -} - function useTasksPageViewState({ initialWorkflows, - initialSteps, initialRepositories, initialTasks, initialTotal, + initialSort, + initialGroup, storeRepositories, }: { initialWorkflows: Workflow[]; - initialSteps: WorkflowStep[]; initialRepositories: Repository[]; initialTasks: Task[]; initialTotal: number; + initialSort: TasksListSort; + initialGroup: TasksListGroup; storeRepositories: Repository[]; }) { - const [workflows] = useState(initialWorkflows); - const [steps] = useState(initialSteps); + const [workflows, setWorkflows] = useState(initialWorkflows); const repositories = storeRepositories.length > 0 ? storeRepositories : initialRepositories; const [tasks, setTasks] = useState(initialTasks); const [total, setTotal] = useState(initialTotal); - const [createDialogOpen, setCreateDialogOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(""); + const [tasksListSort, setTasksListSort] = useState(initialSort); + const [tasksListGroup, setTasksListGroup] = useState(initialGroup); const [showArchived, setShowArchived] = useState(false); const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 25 }); + useEffect(() => { + setWorkflows(initialWorkflows); + }, [initialWorkflows]); + return { workflows, - steps, repositories, tasks, setTasks, total, setTotal, - createDialogOpen, - setCreateDialogOpen, searchQuery, setSearchQuery, + tasksListSort, + setTasksListSort, + tasksListGroup, + setTasksListGroup, showArchived, setShowArchived, pagination, @@ -328,54 +274,24 @@ function useTasksPageEffects({ function useTasksPageComputed({ total, pagination, - workflows, - steps, - repositories, - handleArchive, - handleDelete, - deletingTaskId, router, - activeWorkflowId, }: { total: number; pagination: PaginationState; - workflows: Workflow[]; - steps: WorkflowStep[]; - repositories: Repository[]; - handleArchive: (taskId: string) => Promise; - handleDelete: (taskId: string) => Promise; - deletingTaskId: string | null; router: ReturnType; - activeWorkflowId: string | null; }) { const pageCount = useMemo( () => Math.ceil(total / pagination.pageSize), [total, pagination.pageSize], ); - const columns = useMemo( - () => - getColumns({ - workflows, - steps, - repositories, - onArchive: handleArchive, - onDelete: handleDelete, - deletingTaskId, - }), - [workflows, steps, repositories, handleArchive, handleDelete, deletingTaskId], - ); const handleRowClick = useCallback( (task: Task) => { - router.push(`/tasks/${task.id}`); + router.push(linkToTask(task.id)); }, [router], ); - const defaultWorkflow = activeWorkflowId - ? workflows.find((w) => w.id === activeWorkflowId) - : workflows[0]; - const defaultStep = steps.find((s) => s.workflow_id === defaultWorkflow?.id); - return { pageCount, columns, handleRowClick, defaultWorkflow, defaultStep }; + return { pageCount, handleRowClick }; } function useTasksPageSetup(props: TasksPageClientProps) { @@ -388,10 +304,11 @@ function useTasksPageSetup(props: TasksPageClientProps) { } = useKanbanDisplaySettings(); const viewState = useTasksPageViewState({ initialWorkflows: props.initialWorkflows, - initialSteps: props.initialSteps, initialRepositories: props.initialRepositories, initialTasks: props.initialTasks, initialTotal: props.initialTotal, + initialSort: props.initialSort, + initialGroup: props.initialGroup, storeRepositories, }); const debouncedQuery = useDebounce(viewState.searchQuery, 300); @@ -402,6 +319,7 @@ function useTasksPageSetup(props: TasksPageClientProps) { pagination: viewState.pagination, debouncedQuery, showArchived: viewState.showArchived, + tasksListSort: viewState.tasksListSort, setTasks: viewState.setTasks, setTotal: viewState.setTotal, }); @@ -419,32 +337,142 @@ function useTasksPageSetup(props: TasksPageClientProps) { const computed = useTasksPageComputed({ total: viewState.total, pagination: viewState.pagination, - workflows: viewState.workflows, - steps: viewState.steps, - repositories: viewState.repositories, - handleArchive: ops.handleArchive, - handleDelete: ops.handleDelete, - deletingTaskId: ops.deletingTaskId, router, - activeWorkflowId, }); return { ...viewState, ...ops, ...computed, activeWorkspaceId, debouncedQuery }; } +function useTasksListPreferenceSync({ + tasksListSort, + setTasksListSort, + tasksListGroup, + setTasksListGroup, + setTasks, + setPagination, +}: { + tasksListSort: TasksListSort; + setTasksListSort: (sort: TasksListSort) => void; + tasksListGroup: TasksListGroup; + setTasksListGroup: (group: TasksListGroup) => void; + setTasks: (tasks: Task[] | ((prev: Task[]) => Task[])) => void; + setPagination: (next: PaginationState | ((prev: PaginationState) => PaginationState)) => void; +}) { + const router = useRouter(); + const searchParams = useSearchParams(); + const store = useAppStoreApi(); + + const persistPreferences = useCallback( + (sort: TasksListSort, group: TasksListGroup) => { + const current = store.getState().userSettings; + const setUserSettings = store.getState().setUserSettings; + if (current.tasksListSort === sort && current.tasksListGroup === group) { + return; + } + setUserSettings({ + ...current, + tasksListSort: sort, + tasksListGroup: group, + loaded: true, + }); + updateUserSettings( + { + tasks_list_sort: sort, + tasks_list_group: group, + }, + { cache: "no-store" }, + ).catch(() => {}); + }, + [store], + ); + + useEffect(() => { + const hasSortParam = searchParams.has("sort"); + const hasGroupParam = searchParams.has("group"); + if (!hasSortParam && !hasGroupParam) return; + + const nextSort = hasSortParam ? parseTasksListSort(searchParams.get("sort")) : tasksListSort; + const nextGroup = hasGroupParam + ? parseTasksListGroup(searchParams.get("group")) + : tasksListGroup; + if (nextSort !== tasksListSort) { + setTasksListSort(nextSort); + setTasks((prev) => sortTasksForList(prev, nextSort)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + } + if (nextGroup !== tasksListGroup) { + setTasksListGroup(nextGroup); + } + persistPreferences(nextSort, nextGroup); + }, [ + persistPreferences, + searchParams, + setPagination, + setTasks, + setTasksListGroup, + setTasksListSort, + tasksListGroup, + tasksListSort, + ]); + + const writeUrl = useCallback( + (sort: TasksListSort, group: TasksListGroup) => { + const params = new URLSearchParams(window.location.search); + params.set("sort", sort); + params.set("group", group); + const query = params.toString(); + router.replace(`${window.location.pathname}${query ? `?${query}` : ""}`, { scroll: false }); + }, + [router], + ); + + const handleSortChange = useCallback( + (sort: TasksListSort) => { + setTasksListSort(sort); + setTasks((prev) => sortTasksForList(prev, sort)); + setPagination((prev) => ({ ...prev, pageIndex: 0 })); + writeUrl(sort, tasksListGroup); + persistPreferences(sort, tasksListGroup); + }, + [persistPreferences, setPagination, setTasks, setTasksListSort, tasksListGroup, writeUrl], + ); + + const handleGroupChange = useCallback( + (group: TasksListGroup) => { + setTasksListGroup(group); + writeUrl(tasksListSort, group); + persistPreferences(tasksListSort, group); + }, + [persistPreferences, setTasksListGroup, tasksListSort, writeUrl], + ); + + return { handleSortChange, handleGroupChange }; +} + export function TasksPageClient(props: TasksPageClientProps) { const s = useTasksPageSetup(props); - const { isMobile } = useResponsiveBreakpoint(); - const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); const setMobileSearchOpen = useAppStore((state) => state.setMobileKanbanSearchOpen); + const isMobileSearchOpen = useAppStore((state) => state.mobileKanban.isSearchOpen); + const { isMobile } = useResponsiveBreakpoint(); + const { handleSortChange, handleGroupChange } = useTasksListPreferenceSync({ + tasksListSort: s.tasksListSort, + setTasksListSort: s.setTasksListSort, + tasksListGroup: s.tasksListGroup, + setTasksListGroup: s.setTasksListGroup, + setTasks: s.setTasks, + setPagination: s.setPagination, + }); + + useEffect(() => { + setMobileSearchOpen(false); + return () => setMobileSearchOpen(false); + }, [setMobileSearchOpen]); - // Collapse search on unmount so the global flag doesn't auto-open (and focus) - // the search bar after navigating to another route. - useEffect(() => () => setMobileSearchOpen(false), [setMobileSearchOpen]); return (
)} - -
); diff --git a/apps/web/app/tasks/tasks-pagination.tsx b/apps/web/app/tasks/tasks-pagination.tsx new file mode 100644 index 0000000000..c03e4af862 --- /dev/null +++ b/apps/web/app/tasks/tasks-pagination.tsx @@ -0,0 +1,215 @@ +"use client"; + +import type { ReactNode } from "react"; +import type { PaginationState } from "@tanstack/react-table"; +import { Button } from "@kandev/ui/button"; +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@kandev/ui/pagination"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, +} from "@tabler/icons-react"; + +type TasksPaginationProps = { + total: number; + pageCount: number; + pagination: PaginationState; + onPaginationChange: ( + next: PaginationState | ((prev: PaginationState) => PaginationState), + ) => void; +}; + +type PageNumber = number | "ellipsis"; + +export function TasksPagination({ + total, + pageCount, + pagination, + onPaginationChange, +}: TasksPaginationProps) { + if (total === 0) return null; + + const currentPage = pagination.pageIndex + 1; + const safePageCount = Math.max(1, pageCount); + const pages = getPageNumbers(currentPage, safePageCount); + const start = pagination.pageIndex * pagination.pageSize + 1; + const end = Math.min((pagination.pageIndex + 1) * pagination.pageSize, total); + const canPrevious = pagination.pageIndex > 0; + const canNext = currentPage < safePageCount; + const setPageIndex = (pageIndex: number) => + onPaginationChange((prev) => ({ + ...prev, + pageIndex: Math.max(0, Math.min(pageIndex, safePageCount - 1)), + })); + + return ( +
+
+ Showing {start} to {end} of {total} results +
+
+ + + + setPageIndex(0)} + icon={} + /> + setPageIndex(pagination.pageIndex - 1)} + icon={} + /> + setPageIndex(page - 1)} + /> + setPageIndex(pagination.pageIndex + 1)} + icon={} + /> + setPageIndex(safePageCount - 1)} + icon={} + /> + + +
+
+ ); +} + +function RowsPerPageSelect({ + pagination, + onPaginationChange, +}: Pick) { + return ( +
+

Rows per page

+ +
+ ); +} + +function PageButtons({ + pages, + currentPage, + onPageChange, +}: { + pages: PageNumber[]; + currentPage: number; + onPageChange: (page: number) => void; +}) { + return ( + <> + {pages.map((page, index) => + page === "ellipsis" ? ( + + + + ) : ( + + + + ), + )} + + ); +} + +function PageNavigationButton({ + label, + disabled, + className, + onClick, + icon, +}: { + label: string; + disabled: boolean; + className?: string; + onClick: () => void; + icon: ReactNode; +}) { + return ( + + + + ); +} + +function getPageNumbers(currentPage: number, pageCount: number): PageNumber[] { + if (pageCount <= 5) { + return Array.from({ length: pageCount }, (_, index) => index + 1); + } + + const pages: PageNumber[] = [1]; + if (currentPage > 3) pages.push("ellipsis"); + + const start = Math.max(2, currentPage - 1); + const end = Math.min(pageCount - 1, currentPage + 1); + for (let page = start; page <= end; page += 1) pages.push(page); + + if (currentPage < pageCount - 2) pages.push("ellipsis"); + pages.push(pageCount); + return pages; +} diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index dc7c0822de..25d027dbe3 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -10,6 +10,7 @@ import { useAppStore } from "@/components/state-provider"; type KanbanHeaderMobileProps = { workspaceId?: string; currentPage?: "kanban" | "tasks"; + hideTitle?: boolean; title: string; workspaceLabel: string; searchQuery?: string; @@ -22,6 +23,7 @@ type KanbanHeaderMobileProps = { export function KanbanHeaderMobile({ workspaceId, currentPage = "kanban", + hideTitle = false, title, workspaceLabel, searchQuery = "", @@ -48,18 +50,20 @@ export function KanbanHeaderMobile({ {/* Keep mobile root chrome compact so metrics and actions stay visible. */} - {title} - {!isHome && ( - - {workspaceLabel} - - )} - + hideTitle ? null : ( + + {title} + {!isHome && ( + + {workspaceLabel} + + )} + + ) } actionsClassName="gap-2" actions={ diff --git a/apps/web/components/kanban/kanban-header.tsx b/apps/web/components/kanban/kanban-header.tsx index 75c1f55f6a..9e06ca2bf5 100644 --- a/apps/web/components/kanban/kanban-header.tsx +++ b/apps/web/components/kanban/kanban-header.tsx @@ -25,6 +25,7 @@ import type { ComponentProps, RefObject } from "react"; type KanbanHeaderProps = { workspaceId?: string; currentPage?: "kanban" | "tasks"; + hideTitle?: boolean; searchQuery?: string; onSearchChange?: (query: string) => void; isSearchLoading?: boolean; @@ -43,7 +44,7 @@ const VIEW_TOGGLE_ITEMS: ViewToggleItem[] = [ ]; const WORKBENCH_TOPBAR_CLASSNAME = "h-12 border-b-0 px-3 py-2"; -const DESKTOP_HEADER_NARROW_PX = 1100; +const DESKTOP_HEADER_NARROW_PX = 800; function getWorkspaceLabel( workspaces: Array<{ id: string; name: string }>, @@ -137,6 +138,7 @@ function TabletHeader({ setMenuOpen, showHealthIndicator, onOpenHealthDialog, + hideTitle, }: { title: string; workspaceLabel: string; @@ -148,16 +150,17 @@ function TabletHeader({ setMenuOpen: (open: boolean) => void; showHealthIndicator: boolean; onOpenHealthDialog: () => void; + hideTitle?: boolean; }) { const isHome = title === "Home"; return ( @@ -205,6 +208,7 @@ function DesktopHeader({ handleViewChange, showHealthIndicator, onOpenHealthDialog, + hideTitle, }: { title: string; workspaceLabel: string; @@ -215,6 +219,7 @@ function DesktopHeader({ handleViewChange: (value: string) => void; showHealthIndicator: boolean; onOpenHealthDialog: () => void; + hideTitle?: boolean; }) { const headerRef = useRef(null); const isNarrow = useIsHeaderNarrow(headerRef); @@ -229,20 +234,18 @@ function DesktopHeader({ ) : null; const isHome = title === "Home"; const centerSearch = - isHome && searchInput && !isNarrow ? ( -
{searchInput}
- ) : null; - const actionsSearch = !isHome || isNarrow ? searchInput : null; + searchInput && !isNarrow ?
{searchInput}
: null; + const actionsSearch = isNarrow ? searchInput : null; return ( {actionsSearch} @@ -284,6 +287,7 @@ function useHeaderViewChange( export function KanbanHeader({ workspaceId, currentPage = "kanban", + hideTitle = false, searchQuery = "", onSearchChange, isSearchLoading = false, @@ -314,6 +318,7 @@ export function KanbanHeader({ currentPage={currentPage} title={title} workspaceLabel={workspaceLabel} + hideTitle={hideTitle} {...sharedSearch} {...healthProps} /> @@ -325,6 +330,7 @@ export function KanbanHeader({ ["settings"]>, ) { return { + ...mapTasksListSettingsFields(s), ...mapEditorBehaviorFields(s), ...mapEditorLspFields(s), ...mapEditorSidebarFields(s), @@ -226,6 +228,15 @@ function mapEditorSettingsFields( }; } +function mapTasksListSettingsFields( + s: NonNullable["settings"]>, +) { + return { + tasksListSort: parseTasksListSort(s.tasks_list_sort), + tasksListGroup: parseTasksListGroup(s.tasks_list_group), + }; +} + function mapEditorBehaviorFields( s: NonNullable["settings"]>, ) { diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 10517a2914..27e4ce6077 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -646,6 +646,8 @@ export class ApiClient { default_utility_model?: string; sidebar_views?: unknown[]; kanban_view_mode?: string; + tasks_list_sort?: string; + tasks_list_group?: string; voice_mode?: VoiceModeSettings; }): Promise { await this.request("PATCH", "/api/v1/user/settings", settings); diff --git a/apps/web/e2e/tests/kanban/kanban-board.spec.ts b/apps/web/e2e/tests/kanban/kanban-board.spec.ts index c9620fb3ad..36a1106a6a 100644 --- a/apps/web/e2e/tests/kanban/kanban-board.spec.ts +++ b/apps/web/e2e/tests/kanban/kanban-board.spec.ts @@ -58,8 +58,8 @@ test.describe("Kanban board", () => { // The default Desktop Chrome 1280px viewport now leaves the header below the // 1100px narrow threshold even with no preview open, so this test forces a // viewport wide enough that the centered search shows with the sidebar - // present (1500 − ~320 = ~1180 ≥ 1100), and opening the 500px preview drops - // it below the threshold (1500 − ~320 − 500 = ~680 < 1100). + // present (1500 - ~320 = ~1180 >= 800), and opening the 500px preview drops + // it below the threshold (1500 - ~320 - 500 = ~680 < 800). test("hides header search when preview panel narrows the kanban area", async ({ testPage, apiClient, @@ -75,12 +75,12 @@ test.describe("Kanban board", () => { const kanban = new KanbanPage(testPage); await kanban.goto(); - // With no preview open, the header is wide enough (viewport − sidebar ≥ - // 1100px) that the centered search is visible. + // With no preview open, the header is wide enough (viewport - sidebar >= + // 800px) that the centered search is visible. const search = testPage.getByTestId("kanban-header-search"); await expect(search).toBeVisible(); - // Open the preview — the header width drops below 1100px and the centered + // Open the preview - the header width drops below 800px and the centered // search must hide. const card = kanban.taskCard(task.id); await expect(card).toBeVisible(); diff --git a/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts index 5129b91b14..708309257e 100644 --- a/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts +++ b/apps/web/e2e/tests/task/mobile-task-list-search.spec.ts @@ -1,5 +1,11 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; +async function selectListOption(page: Page, testId: string, optionLabel: string) { + await page.getByTestId(testId).click(); + await page.getByRole("listbox").getByRole("option", { name: optionLabel }).click(); +} + test.describe("Mobile task list search", () => { test("topbar search icon reveals, filters, and clears on collapse", async ({ testPage, @@ -18,36 +24,51 @@ test.describe("Mobile task list search", () => { await testPage.goto("/tasks"); await testPage.waitForLoadState("networkidle"); + const taskList = testPage.getByTestId("tasks-list"); const searchBar = testPage.getByTestId("mobile-search-bar"); const searchToggle = testPage.getByTestId("mobile-search-toggle"); - // Both tasks are listed and search is collapsed behind the topbar icon - await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible(); - await expect(testPage.getByRole("table").getByText("List Beta Task")).toBeVisible(); + await expect(taskList.getByText("List Alpha Task")).toBeVisible(); + await expect(taskList.getByText("List Beta Task")).toBeVisible(); await expect(searchBar).not.toBeVisible(); - // Tapping the icon reveals the focused search input await searchToggle.click(); await expect(searchBar).toBeVisible(); await expect(searchBar.getByPlaceholder("Search tasks...")).toBeFocused(); - // Typing filters the list down to the match await searchBar.getByPlaceholder("Search tasks...").fill("Alpha"); - await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible({ - timeout: 5000, - }); - await expect(testPage.getByRole("table").getByText("List Beta Task")).not.toBeVisible({ - timeout: 5000, - }); + await expect(taskList.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); + await expect(taskList.getByText("List Beta Task")).not.toBeVisible({ timeout: 5000 }); - // Collapsing clears the query so the full list returns await searchToggle.click(); await expect(searchBar).not.toBeVisible(); - await expect(testPage.getByRole("table").getByText("List Alpha Task")).toBeVisible({ - timeout: 5000, + await expect(taskList.getByText("List Alpha Task")).toBeVisible({ timeout: 5000 }); + await expect(taskList.getByText("List Beta Task")).toBeVisible({ timeout: 5000 }); + }); + + test("sort control reorders the compact list", async ({ testPage, apiClient, seedData }) => { + await apiClient.createTask(seedData.workspaceId, "Alpha mobile sort", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, }); - await expect(testPage.getByRole("table").getByText("List Beta Task")).toBeVisible({ - timeout: 5000, + await apiClient.createTask(seedData.workspaceId, "Zulu mobile sort", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, }); + + await testPage.goto("/tasks?group=none"); + await testPage.waitForLoadState("networkidle"); + await testPage.getByTestId("mobile-search-toggle").click(); + await testPage + .getByTestId("mobile-search-bar") + .getByPlaceholder("Search tasks...") + .fill("mobile sort"); + + await selectListOption(testPage, "tasks-list-sort", "Title Z-A"); + + await expect(testPage).toHaveURL((url) => url.searchParams.get("sort") === "title_desc"); + await expect + .poll(() => testPage.getByTestId("tasks-list-row-title").allTextContents()) + .toEqual(["Zulu mobile sort", "Alpha mobile sort"]); }); }); diff --git a/apps/web/e2e/tests/task/task-list-filters.spec.ts b/apps/web/e2e/tests/task/task-list-filters.spec.ts index 47aa944f92..03381c75ee 100644 --- a/apps/web/e2e/tests/task/task-list-filters.spec.ts +++ b/apps/web/e2e/tests/task/task-list-filters.spec.ts @@ -18,13 +18,8 @@ async function gotoTasksPage(page: Page): Promise { await page.getByTestId("display-button").waitFor(); } -/** - * Task title within the /tasks page's data table. The global AppSidebar also - * lists tasks (data-testid="sidebar-task-item"), so a bare getByText matches - * two elements; scoping to the table targets the list this spec is asserting on. - */ function taskInList(page: Page, title: string): Locator { - return page.getByRole("table").getByText(title); + return page.getByTestId("tasks-list").getByText(title); } async function pickListboxOption(page: Page, optionLabel: string): Promise { diff --git a/apps/web/e2e/tests/task/task-list.spec.ts b/apps/web/e2e/tests/task/task-list.spec.ts index af8cc9a0b0..f211145716 100644 --- a/apps/web/e2e/tests/task/task-list.spec.ts +++ b/apps/web/e2e/tests/task/task-list.spec.ts @@ -1,5 +1,15 @@ +import type { Page } from "@playwright/test"; import { test, expect } from "../../fixtures/test-base"; +async function selectListOption(page: Page, testId: string, optionLabel: string) { + await page.getByTestId(testId).click(); + await page.getByRole("listbox").getByRole("option", { name: optionLabel }).click(); +} + +async function taskRowTitles(page: Page): Promise { + return page.getByTestId("tasks-list-row-title").allTextContents(); +} + test.describe("Task List", () => { test("seeded task appears in task list", async ({ testPage, apiClient, seedData }) => { await apiClient.createTask(seedData.workspaceId, "Direct Navigate Task", { @@ -7,12 +17,190 @@ test.describe("Task List", () => { workflow_step_id: seedData.startStepId, }); - // The /tasks page shows all tasks for the workspace in a data table (SSR) + // The /tasks page shows all tasks for the workspace in the list view (SSR) await testPage.goto("/tasks"); await testPage.waitForLoadState("networkidle"); // The global AppSidebar also lists tasks (data-testid="sidebar-task-item"), - // so a bare getByText matches two elements. Scope to the page's data table. - await expect(testPage.getByRole("table").getByText("Direct Navigate Task")).toBeVisible(); + // so a bare getByText matches two elements. Scope to the page list. + await expect( + testPage.getByTestId("tasks-list").getByText("Direct Navigate Task"), + ).toBeVisible(); + await expect(testPage.getByTestId("kanban-header-search")).toBeVisible(); + await expect( + testPage.locator("header").first().getByText("Tasks", { exact: true }), + ).toHaveCount(0); + await expect(testPage.locator("main").getByRole("button", { name: "New Task" })).toHaveCount(0); + + const rowBox = await testPage + .getByTestId("tasks-list-row") + .filter({ hasText: "Direct Navigate Task" }) + .boundingBox(); + expect(rowBox?.height).toBeLessThan(70); + }); + + test("subtasks render under their parent with indentation", async ({ + testPage, + apiClient, + seedData, + }) => { + const parent = await apiClient.createTask(seedData.workspaceId, "Hierarchy Parent Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "Hierarchy Child Task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + parent_id: parent.id, + }); + + await testPage.goto("/tasks"); + await testPage.waitForLoadState("networkidle"); + await testPage + .getByTestId("kanban-header-search") + .getByPlaceholder("Search tasks...") + .fill("Hierarchy"); + + const taskList = testPage.getByTestId("tasks-list"); + const parentRow = taskList.getByTestId("tasks-list-row").filter({ + hasText: "Hierarchy Parent Task", + }); + const childRow = taskList.getByTestId("tasks-list-row").filter({ + hasText: "Hierarchy Child Task", + }); + + await expect(parentRow).toHaveAttribute("data-level", "0", { timeout: 5000 }); + await expect(childRow).toHaveAttribute("data-level", "1", { timeout: 5000 }); + await expect(parentRow).toHaveCount(1); + await expect(childRow).toHaveCount(1); + await expect(taskList.getByTestId("tasks-list-row-title")).toHaveText([ + "Hierarchy Parent Task", + "Hierarchy Child Task", + ]); + }); + + test("sort and group options are deep-linkable and persisted", async ({ + testPage, + apiClient, + seedData, + }) => { + await apiClient.createTask(seedData.workspaceId, "Alpha sort task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + await apiClient.createTask(seedData.workspaceId, "Zulu sort task", { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }); + + await testPage.goto("/tasks?sort=title_desc&group=none"); + await testPage.waitForLoadState("networkidle"); + await testPage + .getByTestId("kanban-header-search") + .getByPlaceholder("Search tasks...") + .fill("sort task"); + + await expect(testPage.getByTestId("tasks-list-sort")).toContainText("Title Z-A"); + await expect(testPage.getByTestId("tasks-list-group")).toContainText("None"); + await expect.poll(() => taskRowTitles(testPage)).toEqual(["Zulu sort task", "Alpha sort task"]); + + await selectListOption(testPage, "tasks-list-sort", "Title A-Z"); + await selectListOption(testPage, "tasks-list-group", "State"); + + await expect(testPage).toHaveURL((url) => { + return ( + url.searchParams.get("sort") === "title_asc" && url.searchParams.get("group") === "state" + ); + }); + await expect.poll(() => taskRowTitles(testPage)).toEqual(["Alpha sort task", "Zulu sort task"]); + + const settings = await apiClient.getUserSettings(); + expect(settings.settings.tasks_list_sort).toBe("title_asc"); + expect(settings.settings.tasks_list_group).toBe("state"); + }); + + test("workflow grouping keeps duplicate workflow names separate", async ({ + testPage, + apiClient, + seedData, + }) => { + const workflowA = await apiClient.createWorkflow(seedData.workspaceId, "Duplicate Group"); + const workflowB = await apiClient.createWorkflow(seedData.workspaceId, "Duplicate Group"); + const stepA = await apiClient.createWorkflowStep(workflowA.id, "Start", 0, { + is_start_step: true, + }); + const stepB = await apiClient.createWorkflowStep(workflowB.id, "Start", 0, { + is_start_step: true, + }); + await apiClient.createTask(seedData.workspaceId, "Duplicate group Alpha", { + workflow_id: workflowA.id, + workflow_step_id: stepA.id, + }); + await apiClient.createTask(seedData.workspaceId, "Duplicate group Beta", { + workflow_id: workflowB.id, + workflow_step_id: stepB.id, + }); + await apiClient.saveUserSettings({ + workspace_id: seedData.workspaceId, + workflow_filter_id: "", + }); + + await testPage.goto("/tasks?sort=title_asc&group=workflow"); + await testPage.waitForLoadState("networkidle"); + await testPage + .getByTestId("kanban-header-search") + .getByPlaceholder("Search tasks...") + .fill("Duplicate group"); + + const sections = testPage.getByTestId("tasks-list-section"); + await expect(sections).toHaveCount(2); + await expect(sections.nth(0).getByTestId("tasks-list-row")).toHaveCount(1); + await expect(sections.nth(1).getByTestId("tasks-list-row")).toHaveCount(1); + await expect + .poll(() => taskRowTitles(testPage)) + .toEqual(["Duplicate group Alpha", "Duplicate group Beta"]); + }); + + test("pagination shows totals, rows per page, and page numbers", async ({ + testPage, + apiClient, + seedData, + }) => { + await Promise.all( + Array.from({ length: 26 }, (_, index) => + apiClient.createTask( + seedData.workspaceId, + `Paged list task ${String(index + 1).padStart(2, "0")}`, + { + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + }, + ), + ), + ); + + await testPage.goto("/tasks?sort=title_asc&group=none"); + await testPage.waitForLoadState("networkidle"); + await testPage + .getByTestId("kanban-header-search") + .getByPlaceholder("Search tasks...") + .fill("Paged list task"); + + const pagination = testPage.getByTestId("tasks-pagination"); + await expect(pagination).toContainText("Showing 1 to 25 of 26 results"); + await expect(testPage.getByTestId("tasks-pagination-page-size")).toContainText("25"); + await expect(pagination.getByRole("button", { name: "1", exact: true })).toHaveAttribute( + "aria-current", + "page", + ); + + await pagination.getByRole("button", { name: "Go to next page" }).click(); + await expect(pagination).toContainText("Showing 26 to 26 of 26 results"); + await expect.poll(() => taskRowTitles(testPage)).toEqual(["Paged list task 26"]); + + await testPage.getByTestId("tasks-pagination-page-size").click(); + await testPage.getByRole("listbox").getByRole("option", { name: "10" }).click(); + await expect(pagination).toContainText("Showing 1 to 10 of 26 results"); + await expect(testPage.getByTestId("tasks-pagination-page-size")).toContainText("10"); }); }); diff --git a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts index 35c44e10f8..620c353dd3 100644 --- a/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts +++ b/apps/web/e2e/tests/task/workspace-switch-sidebar-isolation.spec.ts @@ -27,7 +27,7 @@ async function gotoTaskList(page: Page): Promise { } function taskInList(page: Page, title: string): Locator { - return page.getByRole("table").getByText(title); + return page.getByTestId("tasks-list").getByText(title); } // Office off: the picker routes a workspace switch to `/office` only when the diff --git a/apps/web/hooks/use-ensure-user-settings.test.ts b/apps/web/hooks/use-ensure-user-settings.test.ts index 7aa3d80c68..e2ea77b14d 100644 --- a/apps/web/hooks/use-ensure-user-settings.test.ts +++ b/apps/web/hooks/use-ensure-user-settings.test.ts @@ -50,6 +50,8 @@ function makeUnloadedSettings(): UserSettingsState { workflowId: null, kanbanViewMode: null, repositoryIds: [], + tasksListSort: "updated_desc", + tasksListGroup: "state", preferredShell: null, shellOptions: [], defaultEditorId: null, diff --git a/apps/web/hooks/use-user-display-settings.ts b/apps/web/hooks/use-user-display-settings.ts index 294133a462..11dc1b8715 100644 --- a/apps/web/hooks/use-user-display-settings.ts +++ b/apps/web/hooks/use-user-display-settings.ts @@ -7,6 +7,7 @@ import { useAppStore } from "@/components/state-provider"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { useEnsureUserSettings } from "@/hooks/use-ensure-user-settings"; import { repositoryId, type Repository } from "@/lib/types/http"; +import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; import { DEFAULT_VOICE_MODE_STATE, type UserSettingsState, @@ -59,6 +60,8 @@ function carryForwardCoreSettings(current: DisplaySettings) { savedLayouts: current.savedLayouts ?? [], defaultUtilityAgentId: current.defaultUtilityAgentId ?? null, keyboardShortcuts: current.keyboardShortcuts ?? {}, + tasksListSort: current.tasksListSort ?? DEFAULT_TASKS_LIST_SORT, + tasksListGroup: current.tasksListGroup ?? DEFAULT_TASKS_LIST_GROUP, changesPanelLayout: current.changesPanelLayout ?? "tree", systemMetricsDisplay: current.systemMetricsDisplay ?? { showInTopbar: false }, voiceMode: current.voiceMode ?? { ...DEFAULT_VOICE_MODE_STATE }, diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 995bcab656..7c2fce967e 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -203,6 +203,7 @@ export async function listTasksByWorkspace( includeArchived?: boolean; workflowId?: string | null; repositoryId?: string | null; + sort?: string; } = {}, options?: ApiRequestOptions, ) { @@ -214,5 +215,6 @@ export async function listTasksByWorkspace( if (params.includeArchived) url.searchParams.set("include_archived", "true"); if (params.workflowId) url.searchParams.set("workflow_id", params.workflowId); if (params.repositoryId) url.searchParams.set("repository_id", params.repositoryId); + if (params.sort) url.searchParams.set("sort", params.sort); return fetchJson(url.toString(), options); } diff --git a/apps/web/lib/ssr/user-settings.ts b/apps/web/lib/ssr/user-settings.ts index 70bcfeec55..9fd34813cd 100644 --- a/apps/web/lib/ssr/user-settings.ts +++ b/apps/web/lib/ssr/user-settings.ts @@ -1,3 +1,9 @@ +import { + DEFAULT_TASKS_LIST_GROUP, + DEFAULT_TASKS_LIST_SORT, + parseTasksListGroup, + parseTasksListSort, +} from "@/lib/tasks/tasks-list-options"; import { fromApiSidebarDraft, fromApiSidebarView } from "@/lib/state/slices/ui/sidebar-view-wire"; import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sidebar-view-types"; import { DEFAULT_VOICE_MODE_STATE, type VoiceModeState } from "@/lib/state/slices/settings/types"; @@ -88,6 +94,8 @@ function buildIdentityFields(s: UserSettingsData) { workflowId: s.workflow_filter_id || null, kanbanViewMode: s.kanban_view_mode || null, repositoryIds: s.repository_ids ?? [], + tasksListSort: parseTasksListSort(s.tasks_list_sort), + tasksListGroup: parseTasksListGroup(s.tasks_list_group), preferredShell: s.preferred_shell || null, defaultEditorId: s.default_editor_id || null, defaultUtilityAgentId: s.default_utility_agent_id || null, @@ -149,6 +157,8 @@ export function mapUserSettingsResponse(response: UserSettingsResponse | null) { workflowId: null, kanbanViewMode: null, repositoryIds: [] as string[], + tasksListSort: DEFAULT_TASKS_LIST_SORT, + tasksListGroup: DEFAULT_TASKS_LIST_GROUP, preferredShell: null, shellOptions, defaultEditorId: null, diff --git a/apps/web/lib/state/slices/settings/settings-slice.ts b/apps/web/lib/state/slices/settings/settings-slice.ts index 1d5b6d63c5..ad45b6bdeb 100644 --- a/apps/web/lib/state/slices/settings/settings-slice.ts +++ b/apps/web/lib/state/slices/settings/settings-slice.ts @@ -1,4 +1,5 @@ import type { StateCreator } from "zustand"; +import { DEFAULT_TASKS_LIST_GROUP, DEFAULT_TASKS_LIST_SORT } from "@/lib/tasks/tasks-list-options"; import { DEFAULT_VOICE_MODE_STATE, type SettingsSlice, type SettingsSliceState } from "./types"; export const defaultSettingsState: SettingsSliceState = { @@ -25,6 +26,8 @@ export const defaultSettingsState: SettingsSliceState = { kanbanViewMode: null, workflowId: null, repositoryIds: [], + tasksListSort: DEFAULT_TASKS_LIST_SORT, + tasksListGroup: DEFAULT_TASKS_LIST_GROUP, preferredShell: null, shellOptions: [], defaultEditorId: null, diff --git a/apps/web/lib/state/slices/settings/types.ts b/apps/web/lib/state/slices/settings/types.ts index 38bd4abcfe..cba073f37a 100644 --- a/apps/web/lib/state/slices/settings/types.ts +++ b/apps/web/lib/state/slices/settings/types.ts @@ -20,6 +20,7 @@ import type { SidebarView, SidebarViewDraft } from "@/lib/state/slices/ui/sideba import type { SidebarTaskPrefsState } from "@/lib/state/slices/ui/types"; import type { SecretListItem } from "@/lib/types/http-secrets"; import type { SpritesStatus, SpritesInstance } from "@/lib/types/http-sprites"; +import type { TasksListGroup, TasksListSort } from "@/lib/tasks/tasks-list-options"; export type ExecutorsState = { items: Executor[]; @@ -143,6 +144,8 @@ export type UserSettingsState = { kanbanViewMode: string | null; workflowId: string | null; repositoryIds: string[]; + tasksListSort: TasksListSort; + tasksListGroup: TasksListGroup; preferredShell: string | null; shellOptions: Array<{ value: string; label: string }>; defaultEditorId: string | null; diff --git a/apps/web/lib/tasks/tasks-list-options.ts b/apps/web/lib/tasks/tasks-list-options.ts new file mode 100644 index 0000000000..8c7186fcd7 --- /dev/null +++ b/apps/web/lib/tasks/tasks-list-options.ts @@ -0,0 +1,75 @@ +import type { Task, TaskState } from "@/lib/types/http"; + +export const TASKS_LIST_SORT_OPTIONS = [ + { value: "updated_desc", label: "Updated newest" }, + { value: "updated_asc", label: "Updated oldest" }, + { value: "created_desc", label: "Created newest" }, + { value: "created_asc", label: "Created oldest" }, + { value: "title_asc", label: "Title A-Z" }, + { value: "title_desc", label: "Title Z-A" }, +] as const; + +export const TASKS_LIST_GROUP_OPTIONS = [ + { value: "state", label: "State" }, + { value: "workflow", label: "Workflow" }, + { value: "repository", label: "Repository" }, + { value: "none", label: "None" }, +] as const; + +export type TasksListSort = (typeof TASKS_LIST_SORT_OPTIONS)[number]["value"]; +export type TasksListGroup = (typeof TASKS_LIST_GROUP_OPTIONS)[number]["value"]; + +export const DEFAULT_TASKS_LIST_SORT: TasksListSort = "updated_desc"; +export const DEFAULT_TASKS_LIST_GROUP: TasksListGroup = "state"; + +export const TASK_STATE_ORDER: TaskState[] = [ + "CREATED", + "SCHEDULING", + "TODO", + "IN_PROGRESS", + "REVIEW", + "BLOCKED", + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", +]; + +export function parseTasksListSort(value: string | null | undefined): TasksListSort { + return TASKS_LIST_SORT_OPTIONS.some((option) => option.value === value) + ? (value as TasksListSort) + : DEFAULT_TASKS_LIST_SORT; +} + +export function parseTasksListGroup(value: string | null | undefined): TasksListGroup { + return TASKS_LIST_GROUP_OPTIONS.some((option) => option.value === value) + ? (value as TasksListGroup) + : DEFAULT_TASKS_LIST_GROUP; +} + +export function sortTasksForList(tasks: Task[], sort: TasksListSort): Task[] { + return [...tasks].sort((a, b) => compareTasksForList(a, b, sort)); +} + +export function compareTasksForList(a: Task, b: Task, sort: TasksListSort): number { + const titleCompare = a.title.localeCompare(b.title, undefined, { sensitivity: "base" }); + switch (sort) { + case "updated_asc": + return compareDate(a.updated_at, b.updated_at) || titleCompare; + case "created_desc": + return compareDate(b.created_at, a.created_at) || titleCompare; + case "created_asc": + return compareDate(a.created_at, b.created_at) || titleCompare; + case "title_asc": + return titleCompare || compareDate(b.updated_at, a.updated_at); + case "title_desc": + return -titleCompare || compareDate(b.updated_at, a.updated_at); + case "updated_desc": + default: + return compareDate(b.updated_at, a.updated_at) || titleCompare; + } +} + +function compareDate(a: string, b: string): number { + return Date.parse(a) - Date.parse(b); +} diff --git a/apps/web/lib/types/http-user-settings.ts b/apps/web/lib/types/http-user-settings.ts index 34398069d1..540ddb41de 100644 --- a/apps/web/lib/types/http-user-settings.ts +++ b/apps/web/lib/types/http-user-settings.ts @@ -44,6 +44,8 @@ export type UserSettings = { kanban_view_mode?: string; workflow_filter_id?: string; repository_ids: string[]; + tasks_list_sort?: string; + tasks_list_group?: string; initial_setup_complete?: boolean; preferred_shell?: string; default_editor_id?: string; @@ -88,6 +90,8 @@ export type UserSettingsUpdatePayload = { workflow_filter_id?: string; kanban_view_mode?: string; repository_ids?: string[]; + tasks_list_sort?: string; + tasks_list_group?: string; preferred_shell?: string; default_editor_id?: string; enable_preview_on_click?: boolean; diff --git a/apps/web/src/boot-payload.ts b/apps/web/src/boot-payload.ts index 47ca74fae6..3c089c8276 100644 --- a/apps/web/src/boot-payload.ts +++ b/apps/web/src/boot-payload.ts @@ -31,6 +31,8 @@ export type BootRouteData = { repositories?: Repository[]; tasks?: Task[]; total?: number; + tasksListSort?: string; + tasksListGroup?: string; }; }; diff --git a/apps/web/src/spa-routes.tsx b/apps/web/src/spa-routes.tsx index 8e267fb57e..8ac3e9ebdf 100644 --- a/apps/web/src/spa-routes.tsx +++ b/apps/web/src/spa-routes.tsx @@ -8,6 +8,11 @@ import { StatsPageClient } from "@/app/stats/stats-page-client"; import { isRangeKey } from "@/app/stats/stats-utils"; import type { RangeKey } from "@/app/stats/stats-utils"; import { TasksPageClient } from "@/app/tasks/tasks-page-client"; +import { + parseTasksListGroup, + parseTasksListSort, + sortTasksForList, +} from "@/lib/tasks/tasks-list-options"; import { useAppStore, useAppStoreApi } from "@/components/state-provider"; import type { BootRouteData } from "./boot-payload"; import { fetchJson } from "@/lib/api/client"; @@ -280,22 +285,54 @@ function TasksDataRoute({ bootstrapped: RouteDataState; tasksPage?: BootRouteData["tasksPage"]; }) { + const searchParams = useSearchParams(); + const userSettings = useAppStore((state) => state.userSettings); + const { initialSort, initialGroup } = resolveTasksDataRoutePreferences( + searchParams, + tasksPage, + userSettings, + ); + const initialData = resolveTasksDataRouteInitialData(bootstrapped, tasksPage, initialSort); return ( ); } +function resolveTasksDataRoutePreferences( + searchParams: URLSearchParams, + tasksPage: BootRouteData["tasksPage"] | undefined, + userSettings: { tasksListSort?: string | null; tasksListGroup?: string | null }, +) { + return { + initialSort: parseTasksListSort( + searchParams.get("sort") ?? tasksPage?.tasksListSort ?? userSettings.tasksListSort, + ), + initialGroup: parseTasksListGroup( + searchParams.get("group") ?? tasksPage?.tasksListGroup ?? userSettings.tasksListGroup, + ), + }; +} + +function resolveTasksDataRouteInitialData( + bootstrapped: RouteDataState, + tasksPage: BootRouteData["tasksPage"] | undefined, + initialSort: ReturnType, +) { + return { + initialWorkspaceId: tasksPage?.activeWorkspaceId ?? bootstrapped.activeWorkspaceId ?? undefined, + initialWorkflows: tasksPage?.workflows ?? bootstrapped.workflows, + initialRepositories: tasksPage?.repositories ?? bootstrapped.repositories, + initialTasks: sortTasksForList(tasksPage?.tasks ?? [], initialSort), + initialTotal: tasksPage?.total ?? 0, + initialDataLoaded: Boolean(tasksPage), + }; +} + function ExternalDataRoute({ route, data,