PR #3623
Sections
Review

feat(web): drag-and-drop reorder kanban task cards within a column

main ← feature/enable-drag-and-drop-478 180 files +3200 −400 PR #3623 ↗

Users can now drag a card to a new place inside its own band, and the server persists the order as the single source for board display and WIP promotion.

Why this change

Each column sorts by position, but users could not change it. The board showed newest first, while WIP promotion picked the lowest position, so display and next-up did not match.

What it does

Architecture, end to end

A same-band drop becomes a reorder request. The server serializes on the step, renumbers, bumps revision, and broadcasts. Clients apply optimistically and reconcile by revision.

flowchart LR
  Card[Kanban card] --> Classify[classifyDrop]
  Classify -- same band --> ReorderHook[useStepReorder.reorderBand]
  Classify -- other step --> Move[moveTaskAcrossSteps]
  ReorderHook --> Merge[mergeVisibleReorderIntoBand]
  Merge --> API[PUT /workflow-steps/:id/tasks/reorder]
  API --> Repo[ReorderStepTasks]
  Repo --> DB[(tasks.position + workflow_steps.order_revision)]
  Repo --> Event[task.reordered WS event]
  Event --> Handler[kanban.ts task.reordered handler]
  Handler --> Store[(Zustand kanban + snapshots)]
  ReorderHook -- optimistic --> Store

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

func StepOrderLess(left, right *Task) bool
Click for details →

Defines the total order every surface and promotion uses, with queued_at fallback to created_at.

Comparator
func StepOrderLess(left, right *Task) bool {
  if left.Position != right.Position {
    return left.Position < right.Position
  }
  if lp, rp := stepOrderPriorityRank(left.Priority), stepOrderPriorityRank(right.Priority); lp != rp {
    return lp < rp
  }
  leftQueuedAt, rightQueuedAt := stepOrderEffectiveQueuedAt(left), stepOrderEffectiveQueuedAt(right)
  if !leftQueuedAt.Equal(rightQueuedAt) {
    return leftQueuedAt.Before(rightQueuedAt)
  }
  if !left.CreatedAt.Equal(right.CreatedAt) {
    return left.CreatedAt.Before(right.CreatedAt)
  }
  return left.ID < right.ID
}

func stepOrderEffectiveQueuedAt(task *Task) time.Time {
  if task.QueuedAt != nil {
    return *task.QueuedAt
  }
  return task.CreatedAt
}
func (r *Repository) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) ([]*models.Task, int64, error)
Click for details →

Validates band and membership, renumbers the whole step densely, and bumps order_revision inside a step lock.

Reorder
func (r *Repository) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) ([]*models.Task, int64, error) {
  if band != ReorderBandAdmitted && band != ReorderBandQueued {
    return nil, 0, repoerrors.ErrInvalidReorder
  }
  if err := validateReorderIDList(orderedTaskIDs); err != nil {
    return nil, 0, err
  }
  tx, err := r.db.BeginTx(ctx, nil)
  if err != nil {
    return nil, 0, err
  }
  defer func() { _ = tx.Rollback() }()
  if err := lockWorkflowStepForWrite(ctx, tx, r.db.DriverName(), r.db.Rebind, stepID); err != nil {
    return nil, 0, err
  }
  tasks, err := r.listStepTasksInTx(ctx, tx, stepID)
  if err != nil {
    return nil, 0, err
  }
  admitted, queued := partitionReorderBands(tasks, stepID)
  named := admitted
  other := queued
  if band == ReorderBandQueued {
    named, other = queued, admitted
  }
  orderedNamed, unresolvedIDs := resolveReorderStepMembership(tasks, orderedTaskIDs)
  if len(unresolvedIDs) > 0 {
    return r.reorderUnresolvedIDsResult(ctx, tx, stepID, tasks, unresolvedIDs)
  }
  if !sameTaskSet(orderedNamed, named) {
    revision, revErr := r.stepOrderRevisionInTx(ctx, tx, stepID)
    if revErr != nil {
      return nil, 0, revErr
    }
    return sortStepOrder(tasks), revision, repoerrors.ErrStepChanged
  }
  renumbered := renumberReorderedStep(band, orderedNamed, other)
  for _, task := range renumbered {
    if _, err := tx.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET position = ?, updated_at = ? WHERE id = ?`), task.Position, r.nowUTC(), task.ID); err != nil {
      return nil, 0, err
    }
  }
  revision, err := r.bumpStepOrderRevisionInTx(ctx, tx, stepID)
  if err != nil {
    return nil, 0, err
  }
  if err := tx.Commit(); err != nil {
    return nil, 0, err
  }
  return renumbered, revision, nil
}
func (s *Service) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) (*ReorderStepTasksResult, error)
Click for details →

Authorizes by workflow, delegates to the repository, and publishes task.reordered with the whole-step positions.

Service
func (s *Service) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) (*ReorderStepTasksResult, error) {
  step, err := s.workflowStepGetter.GetStep(ctx, stepID)
  if err != nil {
    return nil, err
  }
  if err := s.authorizeWorkflowScope(ctx, step.WorkflowID, authz.ScopeTaskWrite); err != nil {
    return nil, err
  }
  reorderer, ok := s.tasks.(reorderRepository)
  if !ok {
    return nil, fmt.Errorf("task repository does not support reordering")
  }
  tasks, revision, err := reorderer.ReorderStepTasks(ctx, stepID, band, orderedTaskIDs)
  if err != nil {
    if errors.Is(err, repoerrors.ErrStepChanged) {
      return &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}, err
    }
    return nil, err
  }
  result := &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}
  s.publishTaskReordered(ctx, stepID, band, revision, tasks)
  return result, nil
}
Publish
func (s *Service) publishTaskReordered(ctx context.Context, stepID, band string, revision int64, tasks []*models.Task) {
  if s.eventBus == nil || len(tasks) == 0 {
    return
  }
  taskEntries := make([]map[string]interface{}, len(tasks))
  for i, task := range tasks {
    taskEntries[i] = map[string]interface{}{"id": task.ID, reorderPayloadPositionKey: task.Position}
  }
  data := map[string]interface{}{
    reorderPayloadWorkspaceIDKey: tasks[0].WorkspaceID,
    "workflow_step_id": stepID,
    "band": band,
    "revision": revision,
    "tasks": taskEntries,
  }
  event := bus.NewEvent(events.TaskReordered, "task-service", data)
  _ = s.eventBus.Publish(ctx, events.TaskReordered, event)
}
export function mergeVisibleReorderIntoBand(fullBandOrder: string[], visibleOrderAfterMove: string[], draggedId: string): string[]
Click for details →

Keeps filtered-out tasks in place and moves only the dragged card to its new visible neighbor.

Merge
export function mergeVisibleReorderIntoBand(
  fullBandOrder: string[],
  visibleOrderAfterMove: string[],
  draggedId: string,
): string[] {
  const withoutDragged = fullBandOrder.filter((id) => id !== draggedId);
  const draggedIndex = visibleOrderAfterMove.indexOf(draggedId);
  if (draggedIndex === -1) return fullBandOrder;
  const nextVisibleId = visibleOrderAfterMove[draggedIndex + 1];
  if (nextVisibleId !== undefined) {
    const insertAt = withoutDragged.indexOf(nextVisibleId);
    if (insertAt === -1) return fullBandOrder;
    const result = [...withoutDragged];
    result.splice(insertAt, 0, draggedId);
    return result;
  }
  if (visibleOrderAfterMove[draggedIndex - 1] !== undefined) {
    return [...withoutDragged, draggedId];
  }
  return fullBandOrder;
}
Optimistic reorder with revision reconciliationapps/web/hooks/domains/kanban/use-step-reorder.ts ↗
export function useStepReorder(): { reorderBand, isBandPending }
Click for details →

Applies the new order immediately, calls the reorder API, and reconciles by revision while holding the pending band.

Reorder hook
const nextBandOrder = mergeVisibleReorderIntoBand(currentBandOrder, visibleOrderAfterMove, draggedId);
if (arraysEqual(nextBandOrder, currentBandOrder)) return;
const admittedCount = partitionWipTasks(stepTasks, stepId).admitted.length;
state.setWorkflowSnapshot(workflowId, {
  ...snapshot,
  tasks: applyBandPositions(snapshot.tasks, stepId, band, nextBandOrder, admittedCount),
});
state.setBandReorderPending(stepId, band, true);
try {
  const response = await reorderStepTasks(stepId, { band, ordered_task_ids: nextBandOrder });
  reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: response });
} catch (error) {
  const conflictBody = error instanceof ApiError && error.status === 409 ? (error.body as ReorderStepTasksResponse) : null;
  if (conflictBody) {
    reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: conflictBody });
  } else {
    restoreBandOnFailure({ store, workflowId, stepId, band, originalTasks, current });
    toast({ title: t("task:failedToReorderTasks"), variant: "error" });
  }
} finally {
  state.setBandReorderPending(stepId, band, false);
}
function makeTaskReorderedHandler(store: StoreApi<AppState>): WsHandlers["task.reordered"]
Click for details →

Applies positions only on a strictly greater revision and holds the pending band's slice for later reconciliation.

Handler
return (message) => {
  const { workflow_step_id: stepId, revision, tasks } = message.payload;
  store.setState((state) => {
    const currentRevision = state.kanbanMulti.orderRevisionByStepId[stepId] ?? -1;
    if (revision <= currentRevision) return state;
    const highestHeldRevision = highestHeldReorderRevision(state.kanbanMulti.withheldReorderByBandKey, stepId);
    if (revision <= highestHeldRevision) return state;
    const pendingBands = REORDER_BANDS.filter((band) => state.kanbanMulti.pendingReorderBandKeys[`${stepId}:${band}`]);
    const bandByTaskId = pendingBands.length ? classifyTasksByBand(tasks, stepId, [state.kanban.tasks, ...Object.values(state.kanbanMulti.snapshots).map((s) => s.tasks)]) : new Map();
    const held: Record<ReorderBand, ReorderedTaskPosition[]> = { admitted: [], queued: [] };
    const applyNow: ReorderedTaskPosition[] = [];
    for (const task of tasks) {
      const band = bandByTaskId.get(task.id);
      if (band && pendingBands.includes(band)) held[band].push(task);
      else applyNow.push(task);
    }
    const nextState = holdPendingReorderTasks(state, stepId, pendingBands, held, revision);
    if (applyNow.length === 0) return nextState;
    const positionById = new Map(applyNow.map((task) => [task.id, task.position]));
    return {
      ...nextState,
      kanban: { ...nextState.kanban, tasks: applyPositionsToTasks(nextState.kanban.tasks, positionById) },
      kanbanMulti: { ...nextState.kanbanMulti, orderRevisionByStepId: { ...nextState.kanbanMulti.orderRevisionByStepId, [stepId]: revision } },
    };
  });
};
Read the changes as a list

Step order comparator

apps/backend/internal/task/models/step_order.go

Defines the total order every surface and promotion uses, with queued_at fallback to created_at.

Comparator
func StepOrderLess(left, right *Task) bool {
  if left.Position != right.Position {
    return left.Position < right.Position
  }
  if lp, rp := stepOrderPriorityRank(left.Priority), stepOrderPriorityRank(right.Priority); lp != rp {
    return lp < rp
  }
  leftQueuedAt, rightQueuedAt := stepOrderEffectiveQueuedAt(left), stepOrderEffectiveQueuedAt(right)
  if !leftQueuedAt.Equal(rightQueuedAt) {
    return leftQueuedAt.Before(rightQueuedAt)
  }
  if !left.CreatedAt.Equal(right.CreatedAt) {
    return left.CreatedAt.Before(right.CreatedAt)
  }
  return left.ID < right.ID
}

func stepOrderEffectiveQueuedAt(task *Task) time.Time {
  if task.QueuedAt != nil {
    return *task.QueuedAt
  }
  return task.CreatedAt
}

Reorder repository with band validation

apps/backend/internal/task/repository/sqlite/task_reorder.go

Validates band and membership, renumbers the whole step densely, and bumps order_revision inside a step lock.

Reorder
func (r *Repository) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) ([]*models.Task, int64, error) {
  if band != ReorderBandAdmitted && band != ReorderBandQueued {
    return nil, 0, repoerrors.ErrInvalidReorder
  }
  if err := validateReorderIDList(orderedTaskIDs); err != nil {
    return nil, 0, err
  }
  tx, err := r.db.BeginTx(ctx, nil)
  if err != nil {
    return nil, 0, err
  }
  defer func() { _ = tx.Rollback() }()
  if err := lockWorkflowStepForWrite(ctx, tx, r.db.DriverName(), r.db.Rebind, stepID); err != nil {
    return nil, 0, err
  }
  tasks, err := r.listStepTasksInTx(ctx, tx, stepID)
  if err != nil {
    return nil, 0, err
  }
  admitted, queued := partitionReorderBands(tasks, stepID)
  named := admitted
  other := queued
  if band == ReorderBandQueued {
    named, other = queued, admitted
  }
  orderedNamed, unresolvedIDs := resolveReorderStepMembership(tasks, orderedTaskIDs)
  if len(unresolvedIDs) > 0 {
    return r.reorderUnresolvedIDsResult(ctx, tx, stepID, tasks, unresolvedIDs)
  }
  if !sameTaskSet(orderedNamed, named) {
    revision, revErr := r.stepOrderRevisionInTx(ctx, tx, stepID)
    if revErr != nil {
      return nil, 0, revErr
    }
    return sortStepOrder(tasks), revision, repoerrors.ErrStepChanged
  }
  renumbered := renumberReorderedStep(band, orderedNamed, other)
  for _, task := range renumbered {
    if _, err := tx.ExecContext(ctx, r.db.Rebind(`UPDATE tasks SET position = ?, updated_at = ? WHERE id = ?`), task.Position, r.nowUTC(), task.ID); err != nil {
      return nil, 0, err
    }
  }
  revision, err := r.bumpStepOrderRevisionInTx(ctx, tx, stepID)
  if err != nil {
    return nil, 0, err
  }
  if err := tx.Commit(); err != nil {
    return nil, 0, err
  }
  return renumbered, revision, nil
}

Reorder service and event publish

apps/backend/internal/task/service/service_reorder.go

Authorizes by workflow, delegates to the repository, and publishes task.reordered with the whole-step positions.

Service
func (s *Service) ReorderStepTasks(ctx context.Context, stepID, band string, orderedTaskIDs []string) (*ReorderStepTasksResult, error) {
  step, err := s.workflowStepGetter.GetStep(ctx, stepID)
  if err != nil {
    return nil, err
  }
  if err := s.authorizeWorkflowScope(ctx, step.WorkflowID, authz.ScopeTaskWrite); err != nil {
    return nil, err
  }
  reorderer, ok := s.tasks.(reorderRepository)
  if !ok {
    return nil, fmt.Errorf("task repository does not support reordering")
  }
  tasks, revision, err := reorderer.ReorderStepTasks(ctx, stepID, band, orderedTaskIDs)
  if err != nil {
    if errors.Is(err, repoerrors.ErrStepChanged) {
      return &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}, err
    }
    return nil, err
  }
  result := &ReorderStepTasksResult{WorkflowStepID: stepID, Revision: revision, Tasks: tasks}
  s.publishTaskReordered(ctx, stepID, band, revision, tasks)
  return result, nil
}
Publish
func (s *Service) publishTaskReordered(ctx context.Context, stepID, band string, revision int64, tasks []*models.Task) {
  if s.eventBus == nil || len(tasks) == 0 {
    return
  }
  taskEntries := make([]map[string]interface{}, len(tasks))
  for i, task := range tasks {
    taskEntries[i] = map[string]interface{}{"id": task.ID, reorderPayloadPositionKey: task.Position}
  }
  data := map[string]interface{}{
    reorderPayloadWorkspaceIDKey: tasks[0].WorkspaceID,
    "workflow_step_id": stepID,
    "band": band,
    "revision": revision,
    "tasks": taskEntries,
  }
  event := bus.NewEvent(events.TaskReordered, "task-service", data)
  _ = s.eventBus.Publish(ctx, events.TaskReordered, event)
}

Visible-to-full band merge

apps/web/lib/kanban/reorder-merge.ts

Keeps filtered-out tasks in place and moves only the dragged card to its new visible neighbor.

Merge
export function mergeVisibleReorderIntoBand(
  fullBandOrder: string[],
  visibleOrderAfterMove: string[],
  draggedId: string,
): string[] {
  const withoutDragged = fullBandOrder.filter((id) => id !== draggedId);
  const draggedIndex = visibleOrderAfterMove.indexOf(draggedId);
  if (draggedIndex === -1) return fullBandOrder;
  const nextVisibleId = visibleOrderAfterMove[draggedIndex + 1];
  if (nextVisibleId !== undefined) {
    const insertAt = withoutDragged.indexOf(nextVisibleId);
    if (insertAt === -1) return fullBandOrder;
    const result = [...withoutDragged];
    result.splice(insertAt, 0, draggedId);
    return result;
  }
  if (visibleOrderAfterMove[draggedIndex - 1] !== undefined) {
    return [...withoutDragged, draggedId];
  }
  return fullBandOrder;
}

Optimistic reorder with revision reconciliation

apps/web/hooks/domains/kanban/use-step-reorder.ts

Applies the new order immediately, calls the reorder API, and reconciles by revision while holding the pending band.

Reorder hook
const nextBandOrder = mergeVisibleReorderIntoBand(currentBandOrder, visibleOrderAfterMove, draggedId);
if (arraysEqual(nextBandOrder, currentBandOrder)) return;
const admittedCount = partitionWipTasks(stepTasks, stepId).admitted.length;
state.setWorkflowSnapshot(workflowId, {
  ...snapshot,
  tasks: applyBandPositions(snapshot.tasks, stepId, band, nextBandOrder, admittedCount),
});
state.setBandReorderPending(stepId, band, true);
try {
  const response = await reorderStepTasks(stepId, { band, ordered_task_ids: nextBandOrder });
  reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: response });
} catch (error) {
  const conflictBody = error instanceof ApiError && error.status === 409 ? (error.body as ReorderStepTasksResponse) : null;
  if (conflictBody) {
    reconcileAndApplyReorderResponse({ store, workflowId, stepId, band, candidate: conflictBody });
  } else {
    restoreBandOnFailure({ store, workflowId, stepId, band, originalTasks, current });
    toast({ title: t("task:failedToReorderTasks"), variant: "error" });
  }
} finally {
  state.setBandReorderPending(stepId, band, false);
}

WS task.reordered handler

apps/web/lib/ws/handlers/kanban.ts

Applies positions only on a strictly greater revision and holds the pending band's slice for later reconciliation.

Handler
return (message) => {
  const { workflow_step_id: stepId, revision, tasks } = message.payload;
  store.setState((state) => {
    const currentRevision = state.kanbanMulti.orderRevisionByStepId[stepId] ?? -1;
    if (revision <= currentRevision) return state;
    const highestHeldRevision = highestHeldReorderRevision(state.kanbanMulti.withheldReorderByBandKey, stepId);
    if (revision <= highestHeldRevision) return state;
    const pendingBands = REORDER_BANDS.filter((band) => state.kanbanMulti.pendingReorderBandKeys[`${stepId}:${band}`]);
    const bandByTaskId = pendingBands.length ? classifyTasksByBand(tasks, stepId, [state.kanban.tasks, ...Object.values(state.kanbanMulti.snapshots).map((s) => s.tasks)]) : new Map();
    const held: Record<ReorderBand, ReorderedTaskPosition[]> = { admitted: [], queued: [] };
    const applyNow: ReorderedTaskPosition[] = [];
    for (const task of tasks) {
      const band = bandByTaskId.get(task.id);
      if (band && pendingBands.includes(band)) held[band].push(task);
      else applyNow.push(task);
    }
    const nextState = holdPendingReorderTasks(state, stepId, pendingBands, held, revision);
    if (applyNow.length === 0) return nextState;
    const positionById = new Map(applyNow.map((task) => [task.id, task.position]));
    return {
      ...nextState,
      kanban: { ...nextState.kanban, tasks: applyPositionsToTasks(nextState.kanban.tasks, positionById) },
      kanbanMulti: { ...nextState.kanbanMulti, orderRevisionByStepId: { ...nextState.kanbanMulti.orderRevisionByStepId, [stepId]: revision } },
    };
  });
};

Data and storage

Position is the user-controlled rank. Revision orders events. Both travel in every reorder carrier.

FieldTypeNotes
tasks.positionintegerdense 0..N-1 after reorder, admitted band first
workflow_steps.order_revisionintegermonotonic per step, bumped once per committed reorder
tasks.queued_for_step_idstring | nullband membership: queued when wip_admitted false and equals step id
tasks.wip_admittedbooleanfalse means queued band, true means admitted band
tasks.priorityenumcritical, high, medium, low, else rank 4
tasks.queued_attimestamp | nulleffective value is queued_at or created_at

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Renumbering touches many rows per reorder, but it is serialized on the step and covered by new rigor tests.
  • Revision gating prevents stale WS events from overwriting a newer order, but a missed gate would show the wrong top card.
  • No migration is needed and hidden tasks are excluded, so existing data stays valid.

Trade-offs and review notes

Where to look first

  1. Check ReorderStepTasks validation: band, empty, duplicate, and step_changed on membership mismatch.
  2. Confirm StepOrderLess matches the SQL promotion order, especially the queued_at fallback.
  3. Verify the WS handler holds the pending band and applies the sibling band immediately.
  4. Review mergeVisibleReorderIntoBand under a filter to ensure hidden tasks keep their relative order.