From c163b61b7dffad68a7c0b5521c137bc62fe96671 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sun, 1 Feb 2026 19:07:52 +0000 Subject: [PATCH] feat: all tasks page plus search (#140) --- AGENTS.md | 13 +- .../orchestrator/executor/executor_test.go | 1 + apps/backend/internal/task/controller/task.go | 33 +++ apps/backend/internal/task/dto/requests.go | 7 + .../task/handlers/process_handlers_test.go | 3 + .../internal/task/handlers/task_handlers.go | 32 +++ .../internal/task/repository/interface.go | 1 + .../internal/task/repository/sqlite/task.go | 77 +++++++ .../internal/task/repository/sqlite_test.go | 129 +++++++++++ apps/backend/internal/task/service/service.go | 21 ++ apps/pnpm-lock.yaml | 22 ++ apps/web/app/actions/workspaces.ts | 14 ++ apps/web/app/tasks/columns.tsx | 145 ++++++++++++ apps/web/app/tasks/page.tsx | 69 ++++++ apps/web/app/tasks/tasks-page-client.tsx | 207 ++++++++++++++++++ apps/web/components/kanban-board.tsx | 12 +- apps/web/components/kanban/kanban-header.tsx | 71 +++++- .../components/kanban/task-search-input.tsx | 90 ++++++++ .../settings/workflow-step-editor.tsx | 23 +- apps/web/components/task-create-dialog.tsx | 7 +- .../components/ui/data-table-pagination.tsx | 182 +++++++++++++++ apps/web/components/ui/data-table.tsx | 124 +++++++++++ .../hooks/domains/kanban/use-kanban-data.ts | 34 ++- apps/web/hooks/use-debounce.ts | 47 ++++ apps/web/lib/api/domains/kanban-api.ts | 14 ++ apps/web/lib/links.ts | 4 + apps/web/lib/types/http.ts | 5 + apps/web/package.json | 1 + 28 files changed, 1356 insertions(+), 32 deletions(-) create mode 100644 apps/web/app/tasks/columns.tsx create mode 100644 apps/web/app/tasks/page.tsx create mode 100644 apps/web/app/tasks/tasks-page-client.tsx create mode 100644 apps/web/components/kanban/task-search-input.tsx create mode 100644 apps/web/components/ui/data-table-pagination.tsx create mode 100644 apps/web/components/ui/data-table.tsx create mode 100644 apps/web/hooks/use-debounce.ts diff --git a/AGENTS.md b/AGENTS.md index 625eae239e..8a35deb5db 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -234,11 +234,20 @@ lib/api/domains/{kanban,session,workspace,settings,process}-api.ts # API client ### Frontend - **Data:** SSR fetch → hydrate → read store. Never fetch in components -- **UI Components:** Import shadcn components from `@kandev/ui`, NOT `@/components/ui/*` +- **UI Components:** + - Import shadcn components from `@kandev/ui`, NOT `@/components/ui/*` + - **Always prefer native shadcn components** over custom implementations + - Check `apps/packages/ui/src/` for available components (pagination, table, dialog, etc.) + - For data tables, use `@kandev/ui/table` with TanStack Table; use shadcn Pagination components + - Only create custom components when shadcn doesn't provide what's needed - **Components:** <200 lines, extract to domain components, composition over props - **Hooks:** Domain-organized in `hooks/domains/`, encapsulate subscription + selection - **WS:** Use subscription hooks only; client auto-deduplicates +- **Interactivity:** All buttons and links with actions must have `cursor-pointer` class + +### Plan Implementation +- After implementing a plan, run `make typecheck test lint` to verify the changes --- -**Last Updated**: 2026-01-22 +**Last Updated**: 2026-02-01 diff --git a/apps/backend/internal/orchestrator/executor/executor_test.go b/apps/backend/internal/orchestrator/executor/executor_test.go index 6da824f51d..47e4f14d4c 100644 --- a/apps/backend/internal/orchestrator/executor/executor_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_test.go @@ -178,6 +178,7 @@ func (m *mockRepository) GetTask(ctx context.Context, id string) (*models.Task, func (m *mockRepository) UpdateTask(ctx context.Context, task *models.Task) error { return nil } func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { return nil } func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { return nil, nil } +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { return nil, 0, nil } func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { return nil, nil } func (m *mockRepository) AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error { return nil } func (m *mockRepository) RemoveTaskFromBoard(ctx context.Context, taskID, boardID string) error { return nil } diff --git a/apps/backend/internal/task/controller/task.go b/apps/backend/internal/task/controller/task.go index e40d81be4b..652f1bcec9 100644 --- a/apps/backend/internal/task/controller/task.go +++ b/apps/backend/internal/task/controller/task.go @@ -32,6 +32,39 @@ func (c *TaskController) ListTasks(ctx context.Context, req dto.ListTasksRequest return resp, nil } +func (c *TaskController) ListTasksByWorkspace(ctx context.Context, req dto.ListTasksByWorkspaceRequest) (dto.ListTasksResponse, error) { + tasks, total, err := c.service.ListTasksByWorkspace(ctx, req.WorkspaceID, req.Query, req.Page, req.PageSize) + if err != nil { + return dto.ListTasksResponse{}, err + } + + // Get primary session IDs for all tasks + taskIDs := make([]string, 0, len(tasks)) + for _, task := range tasks { + taskIDs = append(taskIDs, task.ID) + } + + primarySessionMap, err := c.service.GetPrimarySessionIDsForTasks(ctx, taskIDs) + if err != nil { + // Log error but continue without primary session IDs + primarySessionMap = make(map[string]string) + } + + resp := dto.ListTasksResponse{ + Tasks: make([]dto.TaskDTO, 0, len(tasks)), + Total: total, + } + for _, task := range tasks { + var primarySessionID *string + if id, ok := primarySessionMap[task.ID]; ok { + primarySessionID = &id + } + taskDTO := dto.FromTaskWithPrimarySession(task, primarySessionID) + resp.Tasks = append(resp.Tasks, taskDTO) + } + return resp, nil +} + func (c *TaskController) ListTaskSessions(ctx context.Context, req dto.ListTaskSessionsRequest) (dto.ListTaskSessionsResponse, error) { sessions, err := c.service.ListTaskSessions(ctx, req.TaskID) if err != nil { diff --git a/apps/backend/internal/task/dto/requests.go b/apps/backend/internal/task/dto/requests.go index 7649113836..2dcd69b01f 100644 --- a/apps/backend/internal/task/dto/requests.go +++ b/apps/backend/internal/task/dto/requests.go @@ -219,6 +219,13 @@ type ListTasksRequest struct { BoardID string } +type ListTasksByWorkspaceRequest struct { + WorkspaceID string + Query string + Page int + PageSize int +} + type ListTaskSessionsRequest struct { TaskID string } diff --git a/apps/backend/internal/task/handlers/process_handlers_test.go b/apps/backend/internal/task/handlers/process_handlers_test.go index ac05b728bf..1573455fa7 100644 --- a/apps/backend/internal/task/handlers/process_handlers_test.go +++ b/apps/backend/internal/task/handlers/process_handlers_test.go @@ -60,6 +60,9 @@ func (m *mockRepository) DeleteTask(ctx context.Context, id string) error { func (m *mockRepository) ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) { return nil, nil } +func (m *mockRepository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { + return nil, 0, nil +} func (m *mockRepository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) { return nil, nil } diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index 10a12e1540..7b3e8f8219 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "net/http" + "strconv" "github.com/gin-gonic/gin" @@ -58,6 +59,7 @@ func RegisterTaskRoutes(router *gin.Engine, dispatcher *ws.Dispatcher, ctrl *con func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api := router.Group("/api/v1") api.GET("/boards/:id/tasks", h.httpListTasks) + api.GET("/workspaces/:id/tasks", h.httpListTasksByWorkspace) api.GET("/tasks/:id", h.httpGetTask) api.GET("/task-sessions/:id", h.httpGetTaskSession) api.GET("/tasks/:id/sessions", h.httpListTaskSessions) @@ -101,6 +103,36 @@ func (h *TaskHandlers) httpListTasks(c *gin.Context) { c.JSON(http.StatusOK, resp) } +func (h *TaskHandlers) httpListTasksByWorkspace(c *gin.Context) { + page := 1 + pageSize := 50 + + if p := c.Query("page"); p != "" { + if parsed, err := strconv.Atoi(p); err == nil && parsed > 0 { + page = parsed + } + } + if ps := c.Query("page_size"); ps != "" { + if parsed, err := strconv.Atoi(ps); err == nil && parsed > 0 && parsed <= 100 { + pageSize = parsed + } + } + + query := c.Query("query") + + resp, err := h.controller.ListTasksByWorkspace(c.Request.Context(), dto.ListTasksByWorkspaceRequest{ + WorkspaceID: c.Param("id"), + Query: query, + Page: page, + PageSize: pageSize, + }) + if err != nil { + handleNotFound(c, h.logger, err, "tasks not found") + return + } + c.JSON(http.StatusOK, resp) +} + func (h *TaskHandlers) httpGetTask(c *gin.Context) { resp, err := h.controller.GetTask(c.Request.Context(), dto.GetTaskRequest{ID: c.Param("id")}) if err != nil { diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index b6eff0972c..5667f8cf8d 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -22,6 +22,7 @@ type Repository interface { UpdateTask(ctx context.Context, task *models.Task) error DeleteTask(ctx context.Context, id string) error ListTasks(ctx context.Context, boardID string) ([]*models.Task, error) + ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) ListTasksByWorkflowStep(ctx context.Context, workflowStepID string) ([]*models.Task, error) UpdateTaskState(ctx context.Context, id string, state v1.TaskState) error AddTaskToBoard(ctx context.Context, taskID, boardID, workflowStepID string, position int) error diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 8687fd956c..6f0a8d911e 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -135,6 +135,83 @@ func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID return r.scanTasks(rows) } +// ListTasksByWorkspace returns paginated tasks for a workspace with total count +// If query is non-empty, filters by task title, description, repository name, or repository path +func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { + // Calculate offset + offset := (page - 1) * pageSize + if offset < 0 { + offset = 0 + } + + var total int + var rows *sql.Rows + var err error + + if query == "" { + // No search query - use simple query + err = r.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM tasks WHERE workspace_id = ?`, workspaceID).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err = r.db.QueryContext(ctx, ` + SELECT id, workspace_id, board_id, workflow_step_id, title, description, state, priority, position, metadata, created_at, updated_at + FROM tasks + WHERE workspace_id = ? + ORDER BY updated_at DESC + LIMIT ? OFFSET ? + `, workspaceID, pageSize, offset) + } else { + // Search query - use JOIN with repositories + searchPattern := "%" + query + "%" + + err = r.db.QueryRowContext(ctx, ` + SELECT COUNT(DISTINCT t.id) FROM tasks t + LEFT JOIN task_repositories tr ON t.id = tr.task_id + LEFT JOIN repositories r ON tr.repository_id = r.id + WHERE t.workspace_id = ? + AND ( + t.title LIKE ? OR + t.description LIKE ? OR + r.name LIKE ? OR + r.local_path LIKE ? + ) + `, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern).Scan(&total) + if err != nil { + return nil, 0, err + } + + rows, err = r.db.QueryContext(ctx, ` + SELECT DISTINCT t.id, t.workspace_id, t.board_id, t.workflow_step_id, t.title, t.description, t.state, t.priority, t.position, t.metadata, t.created_at, t.updated_at + FROM tasks t + LEFT JOIN task_repositories tr ON t.id = tr.task_id + LEFT JOIN repositories r ON tr.repository_id = r.id + WHERE t.workspace_id = ? + AND ( + t.title LIKE ? OR + t.description LIKE ? OR + r.name LIKE ? OR + r.local_path LIKE ? + ) + ORDER BY t.updated_at DESC + LIMIT ? OFFSET ? + `, workspaceID, searchPattern, searchPattern, searchPattern, searchPattern, pageSize, offset) + } + + if err != nil { + return nil, 0, err + } + defer func() { _ = rows.Close() }() + + tasks, err := r.scanTasks(rows) + if err != nil { + return nil, 0, err + } + + return tasks, total, nil +} + // scanTasks is a helper to scan task rows func (r *Repository) scanTasks(rows *sql.Rows) ([]*models.Task, error) { var result []*models.Task diff --git a/apps/backend/internal/task/repository/sqlite_test.go b/apps/backend/internal/task/repository/sqlite_test.go index 8ef1615895..eede6a967a 100644 --- a/apps/backend/internal/task/repository/sqlite_test.go +++ b/apps/backend/internal/task/repository/sqlite_test.go @@ -349,6 +349,135 @@ func TestSQLiteRepository_ListTasksByWorkflowStep(t *testing.T) { } } +func TestSQLiteRepository_ListTasksByWorkspace(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspaces and board + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-2", Name: "Workspace 2"}) + board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} + _ = repo.CreateBoard(ctx, board) + + // Create tasks in workspace 1 + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task One"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task Two"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Task Three"}) + // Create task in workspace 2 + board2 := &models.Board{ID: "board-456", WorkspaceID: "ws-2", Name: "Test Board 2"} + _ = repo.CreateBoard(ctx, board2) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-4", WorkspaceID: "ws-2", BoardID: "board-456", WorkflowStepID: "step-2", Title: "Task Four"}) + + // Test basic listing without search + tasks, total, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 10) + if err != nil { + t.Fatalf("failed to list tasks by workspace: %v", err) + } + if total != 3 { + t.Errorf("expected total 3 tasks for ws-1, got %d", total) + } + if len(tasks) != 3 { + t.Errorf("expected 3 tasks returned, got %d", len(tasks)) + } + + // Test pagination + tasks, total, err = repo.ListTasksByWorkspace(ctx, "ws-1", "", 1, 2) + if err != nil { + t.Fatalf("failed to list tasks with pagination: %v", err) + } + if total != 3 { + t.Errorf("expected total 3 tasks, got %d", total) + } + if len(tasks) != 2 { + t.Errorf("expected 2 tasks per page, got %d", len(tasks)) + } + + // Test page 2 + tasksPage2, _, err := repo.ListTasksByWorkspace(ctx, "ws-1", "", 2, 2) + if err != nil { + t.Fatalf("failed to list tasks page 2: %v", err) + } + if len(tasksPage2) != 1 { + t.Errorf("expected 1 task on page 2, got %d", len(tasksPage2)) + } +} + +func TestSQLiteRepository_ListTasksByWorkspaceWithSearch(t *testing.T) { + repo, cleanup := createTestSQLiteRepo(t) + defer cleanup() + ctx := context.Background() + + // Create workspace, board, and repository + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace 1"}) + board := &models.Board{ID: "board-123", WorkspaceID: "ws-1", Name: "Test Board"} + _ = repo.CreateBoard(ctx, board) + repository := &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "MyProject", LocalPath: "/home/user/projects/myproject"} + _ = repo.CreateRepository(ctx, repository) + + // Create tasks with different titles and descriptions + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Fix authentication bug", Description: "Users cannot login"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Add new feature", Description: "Implement dark mode"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-3", WorkspaceID: "ws-1", BoardID: "board-123", WorkflowStepID: "step-1", Title: "Refactor codebase", Description: "Clean up authentication module"}) + + // Link task-1 to the repository + _ = 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) + if err != nil { + t.Fatalf("failed to search tasks by title: %v", err) + } + if totalAuth != 2 { + t.Errorf("expected 2 tasks matching 'authentication', got %d", totalAuth) + } + + // Test search by description + tasksDarkMode, totalDarkMode, err := repo.ListTasksByWorkspace(ctx, "ws-1", "dark mode", 1, 10) + if err != nil { + t.Fatalf("failed to search tasks by description: %v", err) + } + if totalDarkMode != 1 { + t.Errorf("expected 1 task matching 'dark mode', got %d", totalDarkMode) + } + if len(tasksDarkMode) != 1 || tasksDarkMode[0].ID != "task-2" { + t.Errorf("expected task-2 to be returned") + } + + // Test search by repository name + tasksRepo, totalRepo, err := repo.ListTasksByWorkspace(ctx, "ws-1", "MyProject", 1, 10) + if err != nil { + t.Fatalf("failed to search tasks by repository name: %v", err) + } + if totalRepo != 1 { + t.Errorf("expected 1 task matching repository 'MyProject', got %d", totalRepo) + } + if len(tasksRepo) != 1 || tasksRepo[0].ID != "task-1" { + t.Errorf("expected task-1 to be returned") + } + + // Test search by repository local_path + _, totalPath, err := repo.ListTasksByWorkspace(ctx, "ws-1", "myproject", 1, 10) + if err != nil { + t.Fatalf("failed to search tasks by repository path: %v", err) + } + if totalPath != 1 { + t.Errorf("expected 1 task matching repository path 'myproject', got %d", totalPath) + } + + // Test search with no results + tasksNone, totalNone, err := repo.ListTasksByWorkspace(ctx, "ws-1", "nonexistent", 1, 10) + if err != nil { + t.Fatalf("failed to search tasks with no results: %v", err) + } + if totalNone != 0 { + t.Errorf("expected 0 tasks matching 'nonexistent', got %d", totalNone) + } + if len(tasksNone) != 0 { + t.Errorf("expected empty tasks slice, got %d tasks", len(tasksNone)) + } +} + func TestSQLiteRepository_Persistence(t *testing.T) { tmpDir := t.TempDir() dbPath := filepath.Join(tmpDir, "persistence_test.db") diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 5156daa905..12061da5d2 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -685,6 +685,27 @@ func (s *Service) ListTasks(ctx context.Context, boardID string) ([]*models.Task return tasks, nil } +// ListTasksByWorkspace returns paginated tasks for a workspace with task repositories loaded. +// If query is non-empty, filters by task title, description, repository name, or repository path. +func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID string, query string, page, pageSize int) ([]*models.Task, int, error) { + tasks, total, err := s.repo.ListTasksByWorkspace(ctx, workspaceID, query, page, pageSize) + if err != nil { + return nil, 0, err + } + + // Load repositories for each task + for _, task := range tasks { + repos, err := s.repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task repositories", zap.String("task_id", task.ID), zap.Error(err)) + } else { + task.Repositories = repos + } + } + + return tasks, total, nil +} + // ListTaskSessions returns all sessions for a task. func (s *Service) ListTaskSessions(ctx context.Context, taskID string) ([]*models.TaskSession, error) { return s.repo.ListTaskSessions(ctx, taskID) diff --git a/apps/pnpm-lock.yaml b/apps/pnpm-lock.yaml index ebb8cb4c44..37c5c38c62 100644 --- a/apps/pnpm-lock.yaml +++ b/apps/pnpm-lock.yaml @@ -333,6 +333,9 @@ importers: '@tabler/icons-react': specifier: ^3.36.1 version: 3.36.1(react@19.2.3) + '@tanstack/react-table': + specifier: ^8.21.3 + version: 8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@tanstack/react-virtual': specifier: ^3.13.18 version: 3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -2604,12 +2607,23 @@ packages: '@tailwindcss/postcss@4.1.18': resolution: {integrity: sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g==} + '@tanstack/react-table@8.21.3': + resolution: {integrity: sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==} + engines: {node: '>=12'} + peerDependencies: + react: '>=16.8' + react-dom: '>=16.8' + '@tanstack/react-virtual@3.13.18': resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + '@tanstack/table-core@8.21.3': + resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} + engines: {node: '>=12'} + '@tanstack/virtual-core@3.13.18': resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} @@ -8820,12 +8834,20 @@ snapshots: postcss: 8.5.6 tailwindcss: 4.1.18 + '@tanstack/react-table@8.21.3(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@tanstack/table-core': 8.21.3 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + '@tanstack/react-virtual@3.13.18(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: '@tanstack/virtual-core': 3.13.18 react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + '@tanstack/table-core@8.21.3': {} + '@tanstack/virtual-core@3.13.18': {} '@ts-morph/common@0.27.0': diff --git a/apps/web/app/actions/workspaces.ts b/apps/web/app/actions/workspaces.ts index 63b28bab4c..93d4dd8d13 100644 --- a/apps/web/app/actions/workspaces.ts +++ b/apps/web/app/actions/workspaces.ts @@ -5,6 +5,7 @@ import type { ApproveSessionResponse, Board, ListBoardsResponse, + ListTasksResponse, ListWorkflowStepsResponse, RepositoryDiscoveryResponse, ListRepositoriesResponse, @@ -94,6 +95,19 @@ export async function listBoardsAction(workspaceId: string): Promise(url.toString()); } +export async function listTasksByWorkspaceAction( + workspaceId: string, + page = 1, + pageSize = 50, + query = '' +): Promise { + 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); + return fetchJson(url.toString()); +} + export async function createBoardAction(payload: { workspace_id: string; name: string; diff --git a/apps/web/app/tasks/columns.tsx b/apps/web/app/tasks/columns.tsx new file mode 100644 index 0000000000..69f4208e25 --- /dev/null +++ b/apps/web/app/tasks/columns.tsx @@ -0,0 +1,145 @@ +"use client" + +import type { ColumnDef } from "@tanstack/react-table" +import type { Task, Board, WorkflowStep, Repository } from "@/lib/types/http" +import Link from "next/link" +import { IconTrash, IconLoader } from "@tabler/icons-react" +import { Button } from "@kandev/ui/button" +import { Badge } from "@kandev/ui/badge" +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip" +import { formatDistanceToNow } from "date-fns" + +type TaskWithResolution = Task & { + boardName?: string + stepName?: string + repositoryNames?: string[] +} + +interface ColumnsConfig { + boards: Board[] + steps: WorkflowStep[] + repositories: Repository[] + onDelete: (taskId: string) => void + deletingTaskId: string | null +} + +export function getColumns({ + boards, + steps, + repositories, + onDelete, + deletingTaskId, +}: ColumnsConfig): ColumnDef[] { + const boardMap = new Map(boards.map((b) => [b.id, b.name])) + const stepMap = new Map(steps.map((s) => [s.id, s.name])) + const repoMap = new Map(repositories.map((r) => [r.id, { name: r.name, path: r.local_path }])) + + return [ + { + accessorKey: "title", + header: "Title", + cell: ({ row }) => { + const task = row.original + const sessionId = task.primary_session_id + if (sessionId) { + return ( + + {task.title} + + ) + } + return {task.title} + }, + }, + { + accessorKey: "board_id", + header: "Board", + cell: ({ row }) => { + const boardName = boardMap.get(row.original.board_id) + return boardName || "-" + }, + }, + { + accessorKey: "workflow_step_id", + header: "Column", + cell: ({ row }) => { + const stepName = stepMap.get(row.original.workflow_step_id) + return stepName || "-" + }, + }, + { + accessorKey: "repositories", + header: "Repository", + cell: ({ row }) => { + const taskRepos = row.original.repositories || [] + if (taskRepos.length === 0) return "-" + const repoInfos = taskRepos + .map((tr) => repoMap.get(tr.repository_id)) + .filter((info): info is { name: string; path: string } => info !== undefined) + if (repoInfos.length === 0) return "-" + + return ( + +
+ {repoInfos.map((info, idx) => ( + + + {info.name} + + +

{info.path || 'No path'}

+
+
+ ))} +
+
+ ) + }, + }, + { + accessorKey: "state", + header: "State", + cell: ({ row }) => { + const state = row.original.state + const variant = state === "COMPLETED" ? "default" : state === "IN_PROGRESS" ? "secondary" : "outline" + return {state} + }, + }, + { + accessorKey: "updated_at", + header: "Updated", + cell: ({ row }) => { + const date = new Date(row.original.updated_at) + return formatDistanceToNow(date, { addSuffix: true }) + }, + }, + { + id: "actions", + header: "Actions", + cell: ({ row }) => { + const isDeleting = deletingTaskId === row.original.id + return ( + + ) + }, + }, + ] +} diff --git a/apps/web/app/tasks/page.tsx b/apps/web/app/tasks/page.tsx new file mode 100644 index 0000000000..54659ef0d3 --- /dev/null +++ b/apps/web/app/tasks/page.tsx @@ -0,0 +1,69 @@ +import { + listWorkspacesAction, + listBoardsAction, + listTasksByWorkspaceAction, + listRepositoriesAction, + listWorkflowStepsAction, +} from '@/app/actions/workspaces'; +import { TasksPageClient } from './tasks-page-client'; +import type { Board, Task, WorkflowStep, Repository, Workspace } from '@/lib/types/http'; + +export default async function TasksPage({ + searchParams, +}: { + searchParams: Promise<{ workspace?: string }>; +}) { + const { workspace: workspaceParam } = await searchParams; + + let workspaces: Workspace[] = []; + let boards: Board[] = []; + let steps: WorkflowStep[] = []; + let repositories: Repository[] = []; + let tasks: Task[] = []; + let total = 0; + let workspaceId = workspaceParam; + + try { + const workspacesResponse = await listWorkspacesAction(); + workspaces = workspacesResponse.workspaces; + + // Use first workspace if none specified + if (!workspaceId && workspaces.length > 0) { + workspaceId = workspaces[0].id; + } + + if (workspaceId) { + // Fetch all data in parallel + const [boardsResponse, repositoriesResponse, tasksResponse] = await Promise.all([ + listBoardsAction(workspaceId), + listRepositoriesAction(workspaceId), + listTasksByWorkspaceAction(workspaceId, 1, 25), + ]); + + boards = boardsResponse.boards; + repositories = repositoriesResponse.repositories; + tasks = tasksResponse.tasks; + total = tasksResponse.total; + + // Fetch workflow steps for each board + const stepsResponses = await Promise.all( + boards.map((board) => listWorkflowStepsAction(board.id)) + ); + steps = stepsResponses.flatMap((r) => r.steps); + } + } catch (error) { + console.error('Failed to load tasks page data:', error); + } + + return ( + + ); +} diff --git a/apps/web/app/tasks/tasks-page-client.tsx b/apps/web/app/tasks/tasks-page-client.tsx new file mode 100644 index 0000000000..493159e53b --- /dev/null +++ b/apps/web/app/tasks/tasks-page-client.tsx @@ -0,0 +1,207 @@ +"use client" + +import { useState, useCallback, useMemo, useEffect } from "react" +import { useRouter } from "next/navigation" +import type { PaginationState } from "@tanstack/react-table" +import { DataTable } from "@/components/ui/data-table" +import { getColumns } from "./columns" +import { deleteTask, listTasksByWorkspace } from "@/lib/api" +import { TaskCreateDialog } from "@/components/task-create-dialog" +import { KanbanHeader } from "@/components/kanban/kanban-header" +import type { Task, Workspace, Board, WorkflowStep, Repository } from "@/lib/types/http" +import { useToast } from "@/components/toast-provider" +import { useKanbanDisplaySettings } from "@/hooks/use-kanban-display-settings" +import { useDebounce } from "@/hooks/use-debounce" + +interface TasksPageClientProps { + workspaces: Workspace[] + initialWorkspaceId?: string + initialBoards: Board[] + initialSteps: WorkflowStep[] + initialRepositories: Repository[] + initialTasks: Task[] + initialTotal: number +} + +export function TasksPageClient({ + initialBoards, + initialSteps, + initialRepositories, + initialTasks, + initialTotal, +}: TasksPageClientProps) { + const router = useRouter() + const { toast } = useToast() + const { + activeWorkspaceId, + activeBoardId, + repositories: storeRepositories, + } = useKanbanDisplaySettings() + + // For columns, we just need id and name from boards - use initial boards for full data + const [boards] = useState(initialBoards) + const [steps] = useState(initialSteps) + // Use store repositories if available for filtering, but fall back to initial + const repositories = storeRepositories.length > 0 ? storeRepositories : initialRepositories + const [tasks, setTasks] = useState(initialTasks) + const [total, setTotal] = useState(initialTotal) + const [isLoading, setIsLoading] = useState(false) + const [createDialogOpen, setCreateDialogOpen] = useState(false) + const [deletingTaskId, setDeletingTaskId] = useState(null) + + // Search state + const [searchQuery, setSearchQuery] = useState('') + const debouncedQuery = useDebounce(searchQuery, 300) + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 25, + }) + + const pageCount = useMemo( + () => Math.ceil(total / pagination.pageSize), + [total, pagination.pageSize] + ) + + const fetchTasks = useCallback(async () => { + if (!activeWorkspaceId) return + setIsLoading(true) + try { + const result = await listTasksByWorkspace(activeWorkspaceId, { + page: pagination.pageIndex + 1, + pageSize: pagination.pageSize, + query: debouncedQuery, + }) + + setTasks(result.tasks) + setTotal(result.total) + } catch (err) { + toast({ + title: "Failed to load tasks", + description: err instanceof Error ? err.message : "Unknown error", + variant: "error", + }) + } finally { + setIsLoading(false) + } + }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, toast]) + + // Reset to page 1 when search query changes + useEffect(() => { + setPagination((prev) => ({ ...prev, pageIndex: 0 })) + }, [debouncedQuery]) + + // Refetch when workspace, pagination, or search query changes + useEffect(() => { + if (activeWorkspaceId) { + fetchTasks() + } + }, [activeWorkspaceId, pagination.pageIndex, pagination.pageSize, debouncedQuery, fetchTasks]) + + const handleCreateTask = useCallback(() => { + setCreateDialogOpen(true) + }, []) + + const handleDelete = useCallback( + async (taskId: string) => { + setDeletingTaskId(taskId) + try { + await deleteTask(taskId) + toast({ + title: "Task deleted", + description: "The task has been deleted successfully.", + }) + fetchTasks() + } catch (err) { + toast({ + title: "Failed to delete task", + description: err instanceof Error ? err.message : "Unknown error", + variant: "error", + }) + } finally { + setDeletingTaskId(null) + } + }, + [fetchTasks, toast] + ) + + const columns = useMemo( + () => + getColumns({ + boards, + steps, + repositories, + onDelete: handleDelete, + deletingTaskId, + }), + [boards, steps, repositories, handleDelete, deletingTaskId] + ) + + const handleRowClick = useCallback( + (task: Task) => { + if (task.primary_session_id) { + router.push(`/s/${task.primary_session_id}`) + } else { + toast({ + title: "No session available", + description: "This task has no associated session yet.", + }) + } + }, + [router, toast] + ) + + const defaultBoard = activeBoardId + ? boards.find((b) => b.id === activeBoardId) + : boards[0] + const defaultColumn = steps.find((s) => s.board_id === defaultBoard?.id) + + return ( +
+ +
+
+

All Tasks

+

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

+
+ + +
+ + {activeWorkspaceId && defaultBoard && defaultColumn && ( + s.board_id === defaultBoard.id) + .map((s) => ({ id: s.id, title: s.name, autoStartAgent: s.behaviors?.autoStartAgent }))} + onSuccess={() => { + setCreateDialogOpen(false) + fetchTasks() + }} + navigateOnSessionCreate={false} + /> + )} +
+ ) +} diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx index dcc0ac1904..50b275d256 100644 --- a/apps/web/components/kanban-board.tsx +++ b/apps/web/components/kanban-board.tsx @@ -35,6 +35,9 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} const store = useAppStoreApi(); const router = useRouter(); + // Search state + const [searchQuery, setSearchQuery] = useState(''); + // Get initial state for actions hook const kanban = useAppStore((state) => state.kanban); const workspaceState = useAppStore((state) => state.workspaces); @@ -70,6 +73,7 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} } = useKanbanData({ onWorkspaceChange: handleWorkspaceChange, onBoardChange: handleBoardChange, + searchQuery, }); // Navigation hook @@ -195,7 +199,12 @@ export function KanbanBoard({ onPreviewTask, onOpenTask }: KanbanBoardProps = {} return (
- + void; + workspaceId?: string; + currentPage?: 'kanban' | 'tasks'; + searchQuery?: string; + onSearchChange?: (query: string) => void; + isSearchLoading?: boolean; }; -export function KanbanHeader({ onCreateTask }: KanbanHeaderProps) { +export function KanbanHeader({ onCreateTask, workspaceId, currentPage = 'kanban', searchQuery = '', onSearchChange, isSearchLoading = false }: KanbanHeaderProps) { + const router = useRouter(); + + const handleViewChange = (value: string) => { + if (value === 'list' && currentPage !== 'tasks') { + router.push(linkToTasks(workspaceId)); + } else if (value === 'kanban' && currentPage !== 'kanban') { + router.push('/'); + } + }; + return ( -
+
KanDev.ai
+ {onSearchChange && ( +
+ +
+ )}
+ + + + + + + + + + Kanban + + + + + + + + + + List + + + + + )} +
+ ); +} diff --git a/apps/web/components/settings/workflow-step-editor.tsx b/apps/web/components/settings/workflow-step-editor.tsx index ebf96c5831..fc2522a59f 100644 --- a/apps/web/components/settings/workflow-step-editor.tsx +++ b/apps/web/components/settings/workflow-step-editor.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'; +import { useMemo, useState, useSyncExternalStore } from 'react'; import { DndContext, closestCenter, @@ -36,26 +36,7 @@ import { SelectValue, } from '@kandev/ui/select'; import type { WorkflowStep, WorkflowStepType, StepBehaviors } from '@/lib/types/http'; - -// Debounce hook for text inputs -function useDebouncedCallback( - callback: (value: string) => void, - delay: number -): (value: string) => void { - const timeoutRef = useRef | null>(null); - - return useCallback( - function debouncedFn(value: string) { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - timeoutRef.current = setTimeout(() => { - callback(value); - }, delay); - }, - [callback, delay] - ); -} +import { useDebouncedCallback } from '@/hooks/use-debounce'; const STEP_COLORS = [ { value: 'bg-slate-500', label: 'Gray' }, diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index cf24c445cb..b66553dc87 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -75,6 +75,8 @@ interface TaskCreateDialogProps { }; submitLabel?: string; taskId?: string | null; + /** Whether to navigate to the new session after task creation. Defaults to true. */ + navigateOnSessionCreate?: boolean; } type RepositoryOption = { @@ -319,6 +321,7 @@ export function TaskCreateDialog({ initialValues, submitLabel = 'Create', taskId = null, + navigateOnSessionCreate = true, }: TaskCreateDialogProps) { const router = useRouter(); const isSessionMode = mode === 'session'; @@ -855,8 +858,8 @@ export function TaskCreateDialog({ setEnvironmentId(''); setExecutorId(''); - // Navigate to the new session if one was created (this will unmount the dialog) - if (newSessionId) { + // Navigate to the new session if one was created and navigation is enabled + if (newSessionId && navigateOnSessionCreate) { router.push(linkToSession(newSessionId)); } else { onOpenChange(false); diff --git a/apps/web/components/ui/data-table-pagination.tsx b/apps/web/components/ui/data-table-pagination.tsx new file mode 100644 index 0000000000..cb63ffd99b --- /dev/null +++ b/apps/web/components/ui/data-table-pagination.tsx @@ -0,0 +1,182 @@ +"use client" + +import { type Table } from "@tanstack/react-table" +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, +} from "@kandev/ui/pagination" +import { Button } from "@kandev/ui/button" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@kandev/ui/select" +import { + IconChevronLeft, + IconChevronRight, + IconChevronsLeft, + IconChevronsRight, +} from "@tabler/icons-react" + +interface DataTablePaginationProps { + table: Table +} + +export function DataTablePagination({ + table, +}: DataTablePaginationProps) { + const currentPage = table.getState().pagination.pageIndex + 1 + const pageCount = table.getPageCount() + + // Generate page numbers to display + const getPageNumbers = () => { + const pages: (number | 'ellipsis')[] = [] + const showPages = 5 // Max pages to show + + if (pageCount <= showPages) { + // Show all pages + for (let i = 1; i <= pageCount; i++) { + pages.push(i) + } + } else { + // Always show first page + pages.push(1) + + if (currentPage > 3) { + pages.push('ellipsis') + } + + // Pages around current + const start = Math.max(2, currentPage - 1) + const end = Math.min(pageCount - 1, currentPage + 1) + + for (let i = start; i <= end; i++) { + pages.push(i) + } + + if (currentPage < pageCount - 2) { + pages.push('ellipsis') + } + + // Always show last page + if (pageCount > 1) { + pages.push(pageCount) + } + } + + return pages + } + + return ( +
+
+ {table.getFilteredRowModel().rows.length > 0 && ( + <> + Showing {table.getState().pagination.pageIndex * table.getState().pagination.pageSize + 1} to{" "} + {Math.min( + (table.getState().pagination.pageIndex + 1) * table.getState().pagination.pageSize, + table.getRowCount() + )}{" "} + of {table.getRowCount()} results + + )} +
+
+
+

Rows per page

+ +
+ + + + + + + + + {getPageNumbers().map((page, idx) => + page === 'ellipsis' ? ( + + + + ) : ( + + + + ) + )} + + + + + + + + +
+
+ ) +} diff --git a/apps/web/components/ui/data-table.tsx b/apps/web/components/ui/data-table.tsx new file mode 100644 index 0000000000..c21d3d8034 --- /dev/null +++ b/apps/web/components/ui/data-table.tsx @@ -0,0 +1,124 @@ +"use client" + +import { + type ColumnDef, + type PaginationState, + flexRender, + getCoreRowModel, + useReactTable, +} from "@tanstack/react-table" + +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@kandev/ui/table" +import { DataTablePagination } from "./data-table-pagination" + +interface DataTableProps { + columns: ColumnDef[] + data: TData[] + pageCount?: number + pagination?: PaginationState + onPaginationChange?: (pagination: PaginationState) => void + isLoading?: boolean + onRowClick?: (row: TData) => void +} + +export function DataTable({ + columns, + data, + pageCount = -1, + pagination, + onPaginationChange, + isLoading = false, + onRowClick, +}: DataTableProps) { + const table = useReactTable({ + data, + columns, + pageCount, + state: { + pagination, + }, + onPaginationChange: onPaginationChange + ? (updater) => { + const newPagination = + typeof updater === "function" ? updater(pagination!) : updater + onPaginationChange(newPagination) + } + : undefined, + getCoreRowModel: getCoreRowModel(), + manualPagination: true, + }) + + return ( +
+
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ))} + + ))} + + + {isLoading ? ( + + + Loading... + + + ) : table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + onRowClick?.(row.original)} + > + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + No results. + + + )} + +
+
+ {pagination && onPaginationChange && ( + + )} +
+ ) +} diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts index 4f1711e590..0d2cb1c072 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-data.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -11,9 +11,10 @@ import type { WorkflowStep } from '@/components/kanban-column'; type KanbanDataOptions = { onWorkspaceChange: (workspaceId: string | null) => void; onBoardChange: (boardId: string | null) => void; + searchQuery?: string; }; -export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOptions) { +export function useKanbanData({ onWorkspaceChange, onBoardChange, searchQuery = '' }: KanbanDataOptions) { const [taskSessionAvailability, setTaskSessionAvailability] = useState>({}); // Store selectors @@ -21,6 +22,7 @@ export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOp const workspaceState = useAppStore((state) => state.workspaces); const boardsState = useAppStore((state) => state.boards); const enablePreviewOnClick = useAppStore((state) => state.userSettings.enablePreviewOnClick); + const repositoriesByWorkspace = useAppStore((state) => state.repositories.itemsByWorkspaceId); // Data fetching hooks useBoards(workspaceState.activeId, true); @@ -77,13 +79,39 @@ export function useKanbanData({ onWorkspaceChange, onBoardChange }: KanbanDataOp [tasks, selectedRepositoryIds] ); + // Apply search filtering + const filteredTasks = useMemo(() => { + if (!searchQuery) return visibleTasks; + + // Get repositories for the current workspace for search filtering + const repositories = workspaceState.activeId + ? repositoriesByWorkspace[workspaceState.activeId] ?? [] + : []; + + const query = searchQuery.toLowerCase(); + return visibleTasks.filter((task) => { + // Match task title or description + if (task.title.toLowerCase().includes(query)) return true; + if (task.description?.toLowerCase().includes(query)) return true; + + // Match repository name/path + if (task.repositoryId) { + const repo = repositories.find((r) => r.id === task.repositoryId); + if (repo?.name?.toLowerCase().includes(query)) return true; + if (repo?.local_path?.toLowerCase().includes(query)) return true; + } + + return false; + }); + }, [visibleTasks, searchQuery, workspaceState.activeId, repositoriesByWorkspace]); + const visibleTasksWithSessions = useMemo( () => - visibleTasks.map((task) => ({ + filteredTasks.map((task) => ({ ...task, hasSession: taskSessionAvailability[task.id], })), - [visibleTasks, taskSessionAvailability] + [filteredTasks, taskSessionAvailability] ); return { diff --git a/apps/web/hooks/use-debounce.ts b/apps/web/hooks/use-debounce.ts new file mode 100644 index 0000000000..a28670eb99 --- /dev/null +++ b/apps/web/hooks/use-debounce.ts @@ -0,0 +1,47 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +/** + * Debounces a value. Returns the debounced value that updates after the specified delay. + */ +export function useDebounce(value: T, delay: number): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + const handler = setTimeout(() => { + setDebouncedValue(value); + }, delay); + + return () => { + clearTimeout(handler); + }; + }, [value, delay]); + + return debouncedValue; +} + +/** + * Returns a debounced version of the callback function. + */ +export function useDebouncedCallback( + callback: (value: string) => void, + delay: number +): (value: string) => void { + const timeoutRef = useRef | null>(null); + const callbackRef = useRef(callback); + + useEffect(() => { + callbackRef.current = callback; + }, [callback]); + + return useCallback( + (value: string) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + timeoutRef.current = setTimeout(() => { + callbackRef.current(value); + }, delay); + }, + [delay] + ); +} diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index 396abf9f4c..999d0e1606 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -3,6 +3,7 @@ import { getBackendConfig } from '@/lib/config'; import type { BoardSnapshot, ListBoardsResponse, + ListTasksResponse, CreateTaskResponse, Task, MoveTaskResponse, @@ -90,3 +91,16 @@ export async function moveTask( export async function fetchTask(taskId: string, options?: ApiRequestOptions) { return fetchJson(`/api/v1/tasks/${taskId}`, options); } + +export async function listTasksByWorkspace( + workspaceId: string, + params: { page?: number; pageSize?: number; query?: string } = {}, + options?: ApiRequestOptions +) { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const url = new URL(`${baseUrl}/api/v1/workspaces/${workspaceId}/tasks`); + if (params.page) url.searchParams.set('page', String(params.page)); + if (params.pageSize) url.searchParams.set('page_size', String(params.pageSize)); + if (params.query) url.searchParams.set('query', params.query); + return fetchJson(url.toString(), options); +} diff --git a/apps/web/lib/links.ts b/apps/web/lib/links.ts index 9ba5d90476..ac548fa264 100644 --- a/apps/web/lib/links.ts +++ b/apps/web/lib/links.ts @@ -1,3 +1,7 @@ export function linkToSession(sessionId: string): string { return `/s/${sessionId}`; } + +export function linkToTasks(workspaceId?: string): string { + return workspaceId ? `/tasks?workspace=${workspaceId}` : '/tasks'; +} diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 339874da05..2292c3296f 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -367,6 +367,11 @@ export type ListBoardsResponse = { total: number; }; +export type ListTasksResponse = { + tasks: Task[]; + total: number; +}; + export type ListRepositoriesResponse = { repositories: Repository[]; total: number; diff --git a/apps/web/package.json b/apps/web/package.json index 1b47159d35..2f04dc92b3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -45,6 +45,7 @@ "@milkdown/theme-nord": "^7.18.0", "@milkdown/utils": "^7.18.0", "@tabler/icons-react": "^3.36.1", + "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.18", "@uiw/codemirror-theme-vscode": "^4.25.4", "@uiw/react-codemirror": "^4.25.4",