From f4723ebcae8a9c0773b7d755f30d773ce4b45722 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Tue, 31 Mar 2026 23:31:18 +0100 Subject: [PATCH] feat: sort tasks by creation date in kanban and sidebar (#511) --- .../internal/task/repository/sqlite/task.go | 4 +- apps/web/components/kanban-board-grid.tsx | 2 +- apps/web/components/kanban-card.tsx | 1 + .../kanban/swimlane-graph-content.tsx | 2 +- .../kanban/swimlane-kanban-content.tsx | 7 ++- .../components/kanban/swipeable-columns.tsx | 2 +- .../components/task/task-session-sidebar.tsx | 2 + apps/web/components/task/task-switcher.tsx | 10 +++-- .../kanban/use-all-workflow-snapshots.ts | 1 + .../hooks/domains/kanban/use-kanban-data.ts | 1 + apps/web/lib/state/slices/kanban/types.ts | 1 + apps/web/lib/ws/handlers/tasks.ts | 44 ++++++++++++++----- 12 files changed, 55 insertions(+), 22 deletions(-) diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 3e4b96f3c8..b5c33c1735 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -129,7 +129,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks WHERE workflow_id = ? AND archived_at IS NULL AND is_ephemeral = 0 - ORDER BY position + ORDER BY created_at ASC `), workflowID) if err != nil { return nil, err @@ -164,7 +164,7 @@ func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, workspace_id, workflow_id, workflow_step_id, title, description, state, priority, position, metadata, is_ephemeral, parent_id, archived_at, created_at, updated_at FROM tasks - WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0 ORDER BY position + WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0 ORDER BY created_at ASC `), workflowStepID) if err != nil { return nil, err diff --git a/apps/web/components/kanban-board-grid.tsx b/apps/web/components/kanban-board-grid.tsx index 190e4ee273..3b6444a716 100644 --- a/apps/web/components/kanban-board-grid.tsx +++ b/apps/web/components/kanban-board-grid.tsx @@ -59,7 +59,7 @@ function getTasksForStep(tasks: Task[], stepId: string) { return tasks .filter((task) => task.workflowStepId === stepId) .map((task) => ({ ...task, position: task.position ?? 0 })) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")); } function EmptyState({ showLoading }: { showLoading: boolean }) { diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx index 9260b4ea4d..d4ca4f2125 100644 --- a/apps/web/components/kanban-card.tsx +++ b/apps/web/components/kanban-card.tsx @@ -59,6 +59,7 @@ export interface Task { isRemoteExecutor?: boolean; parentTaskId?: string | null; updatedAt?: string; + createdAt?: string; } export interface WorkflowStep { diff --git a/apps/web/components/kanban/swimlane-graph-content.tsx b/apps/web/components/kanban/swimlane-graph-content.tsx index 07e472bebc..ff88c5f602 100644 --- a/apps/web/components/kanban/swimlane-graph-content.tsx +++ b/apps/web/components/kanban/swimlane-graph-content.tsx @@ -194,7 +194,7 @@ function useSwimlaneGraphDnd({ tasks, steps, workflowId, onMoveError }: Swimlane for (const col of steps) { map[col.id] = tasks .filter((t) => t.workflowStepId === col.id) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")); } return map; }, [steps, tasks]); diff --git a/apps/web/components/kanban/swimlane-kanban-content.tsx b/apps/web/components/kanban/swimlane-kanban-content.tsx index 479b2b341b..722e0cef85 100644 --- a/apps/web/components/kanban/swimlane-kanban-content.tsx +++ b/apps/web/components/kanban/swimlane-kanban-content.tsx @@ -79,9 +79,8 @@ function useSwimlaneKanbanDnd({ tasks, workflowId, onMoveError }: SwimlaneKanban .filter( (t: KanbanState["tasks"][number]) => t.workflowStepId === targetStepId && t.id !== taskId, ) - .sort( - (a: KanbanState["tasks"][number], b: KanbanState["tasks"][number]) => - a.position - b.position, + .sort((a: KanbanState["tasks"][number], b: KanbanState["tasks"][number]) => + (a.createdAt ?? "").localeCompare(b.createdAt ?? ""), ); const nextPosition = targetTasks.length; const originalTasks = snapshot.tasks; @@ -164,7 +163,7 @@ function useTasksByStep(tasks: Task[]) { (stepId: string) => tasks .filter((t) => t.workflowStepId === stepId) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)), + .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")), [tasks], ); } diff --git a/apps/web/components/kanban/swipeable-columns.tsx b/apps/web/components/kanban/swipeable-columns.tsx index 9305c0f222..51c3a98db7 100644 --- a/apps/web/components/kanban/swipeable-columns.tsx +++ b/apps/web/components/kanban/swipeable-columns.tsx @@ -55,7 +55,7 @@ export function SwipeableColumns({ return tasks .filter((task) => task.workflowStepId === stepId) .map((task) => ({ ...task, position: task.position ?? 0 })) - .sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")); }, [tasks], ); diff --git a/apps/web/components/task/task-session-sidebar.tsx b/apps/web/components/task/task-session-sidebar.tsx index dd2914104f..5d8fcc1981 100644 --- a/apps/web/components/task/task-session-sidebar.tsx +++ b/apps/web/components/task/task-session-sidebar.tsx @@ -143,6 +143,7 @@ function toSidebarItem( remoteExecutorName: task.primaryExecutorName ?? undefined, primarySessionId: task.primarySessionId ?? null, updatedAt: sessionInfo.updatedAt ?? task.updatedAt, + createdAt: task.createdAt, isArchived: false as boolean, parentTaskTitle: task.parentTaskId ? ctx.titleById.get(task.parentTaskId) : undefined, }; @@ -232,6 +233,7 @@ function useSidebarData(workspaceId: string | null) { remoteExecutorName: undefined, primarySessionId: null, updatedAt: archivedState.archivedTaskUpdatedAt, + createdAt: undefined, isArchived: true, parentTaskTitle: undefined, }); diff --git a/apps/web/components/task/task-switcher.tsx b/apps/web/components/task/task-switcher.tsx index ac32246b8c..35a313e1ca 100644 --- a/apps/web/components/task/task-switcher.tsx +++ b/apps/web/components/task/task-switcher.tsx @@ -34,6 +34,7 @@ type TaskSwitcherItem = { remoteExecutorType?: string; remoteExecutorName?: string; updatedAt?: string; + createdAt?: string; isArchived?: boolean; primarySessionId?: string | null; parentTaskTitle?: string; @@ -206,10 +207,13 @@ export const TaskSwitcher = memo(function TaskSwitcher({ else backlog.push(task); } + const byCreatedAt = (a: TaskSwitcherItem, b: TaskSwitcherItem) => + (a.createdAt ?? "").localeCompare(b.createdAt ?? ""); + return [ - { label: "Turn Finished", tasks: review }, - { label: "Running", tasks: inProgress }, - { label: "Backlog", tasks: backlog }, + { label: "Turn Finished", tasks: review.sort(byCreatedAt) }, + { label: "Running", tasks: inProgress.sort(byCreatedAt) }, + { label: "Backlog", tasks: backlog.sort(byCreatedAt) }, ] satisfies Section[]; }, [tasks]); diff --git a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts index 0b7476b47f..9b92043a67 100644 --- a/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts +++ b/apps/web/hooks/domains/kanban/use-all-workflow-snapshots.ts @@ -29,6 +29,7 @@ function mapSnapshotTask(task: Task, stepIds: Set): KanbanTask | null { isRemoteExecutor: task.is_remote_executor ?? false, parentTaskId: task.parent_id ?? undefined, updatedAt: task.updated_at, + createdAt: task.created_at, } as KanbanTask; } diff --git a/apps/web/hooks/domains/kanban/use-kanban-data.ts b/apps/web/hooks/domains/kanban/use-kanban-data.ts index 24bf989b57..8b37eee385 100644 --- a/apps/web/hooks/domains/kanban/use-kanban-data.ts +++ b/apps/web/hooks/domains/kanban/use-kanban-data.ts @@ -75,6 +75,7 @@ export function useKanbanData({ sessionCount: task.sessionCount, reviewStatus: task.reviewStatus, parentTaskId: task.parentTaskId, + createdAt: task.createdAt, })); const activeSteps = kanban.workflowId ? steps : []; diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index cf5da3873c..b77e83d123 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -38,6 +38,7 @@ export type KanbanState = { isRemoteExecutor?: boolean; parentTaskId?: string | null; updatedAt?: string; + createdAt?: string; }>; isLoading?: boolean; }; diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 44530b3a7d..68bda80c34 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -11,6 +11,39 @@ function withFallback(value: T | null | undefined, fallback: T | undefined): return value ?? fallback; } +function buildNullableFields( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, + existing?: KanbanTask, +): Pick< + KanbanTask, + | "repositoryId" + | "primarySessionId" + | "primarySessionState" + | "sessionCount" + | "reviewStatus" + | "primaryExecutorId" + | "primaryExecutorType" + | "primaryExecutorName" + | "parentTaskId" + | "updatedAt" + | "createdAt" +> { + return { + repositoryId: withFallback(payload.repository_id, existing?.repositoryId), + primarySessionId: withFallback(payload.primary_session_id, existing?.primarySessionId), + primarySessionState: withFallback(payload.primary_session_state, existing?.primarySessionState), + sessionCount: withFallback(payload.session_count, existing?.sessionCount), + reviewStatus: withFallback(payload.review_status, existing?.reviewStatus), + primaryExecutorId: withFallback(payload.primary_executor_id, existing?.primaryExecutorId), + primaryExecutorType: withFallback(payload.primary_executor_type, existing?.primaryExecutorType), + primaryExecutorName: withFallback(payload.primary_executor_name, existing?.primaryExecutorName), + parentTaskId: withFallback(payload.parent_id, existing?.parentTaskId), + updatedAt: withFallback(payload.updated_at, existing?.updatedAt), + createdAt: withFallback(payload.created_at, existing?.createdAt), + }; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask { return { @@ -20,17 +53,8 @@ function buildTaskFromPayload(payload: any, existing?: KanbanTask): KanbanTask { description: payload.description, position: payload.position ?? 0, state: payload.state, - repositoryId: withFallback(payload.repository_id, existing?.repositoryId), - primarySessionId: withFallback(payload.primary_session_id, existing?.primarySessionId), - primarySessionState: withFallback(payload.primary_session_state, existing?.primarySessionState), - sessionCount: withFallback(payload.session_count, existing?.sessionCount), - reviewStatus: withFallback(payload.review_status, existing?.reviewStatus), - primaryExecutorId: withFallback(payload.primary_executor_id, existing?.primaryExecutorId), - primaryExecutorType: withFallback(payload.primary_executor_type, existing?.primaryExecutorType), - primaryExecutorName: withFallback(payload.primary_executor_name, existing?.primaryExecutorName), isRemoteExecutor: payload.is_remote_executor ?? existing?.isRemoteExecutor ?? false, - parentTaskId: withFallback(payload.parent_id, existing?.parentTaskId), - updatedAt: withFallback(payload.updated_at, existing?.updatedAt), + ...buildNullableFields(payload, existing), }; }