TaskKanbanBoard.tsx59.1 KBView on GitHub
'use client';

/**
 * TaskKanbanBoard — one column per task group, plus Upcoming.
 *
 * Placement:  dueDate <= end of today  → the task's own group column (Misc when ungrouped)
 *             dueDate >  end of today  → the single Upcoming column, whatever its group
 * Ordering:   the toolbar's Ordering, via `compareTasks` — dueDate descending by default (most
 *             recent on top, sliding down to the most overdue). Under `manual` each card sorts
 *             by its own stored `sortOrder`, which is the only mode a vertical drag can change.
 *             See TASK_REORDERING_DESIGN.md.
 *
 * The board reads `listUserTasks` directly rather than the agenda documents, so it is *unfloored*:
 * the agenda's Current view drops anything older than 30 days, which on a real account hides the
 * large majority of open tasks. The board is where that backlog is visible and sweepable.
 *
 * Column membership and order are mirrored by `cedar-cli groups board`, which projects the same
 * rules server-side — that's the headless assertion surface for this component.
 */
import {
  DndContext,
  DragOverlay,
  MeasuringStrategy,
  PointerSensor,
  closestCorners,
  useSensor,
  useSensors,
  type DragStartEvent,
} from '@dnd-kit/core';
import {
  TASK_OUTPUT_KINDS,
  TASK_OUTPUT_KIND_LABELS,
  UNDECIDED_OUTPUT_KEY,
} from '@/modules/userTasks/utils/task-output';
import {
  MISC_KEY,
  UPCOMING_KEY,
  insertionIndexForY,
  planTaskDrop,
} from '@/modules/userTasks/utils/task-drop-plan';
import { useOpenTaskInExecutionMode } from '@/modules/userTasks/hooks/use-open-task-in-execution-mode';
import { useOptimisticTaskActions } from '@/modules/userTasks/hooks/use-optimistic-task-actions';
import { useTaskListViewOptions } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { openConversationFromAgenda } from '@/modules/agentCanvas/utils/open-conversation';
import { patchCachedGroup, listGroupsInput } from '@/modules/userTasks/utils/group-cache';
import type { TaskColumnBy } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { useHydrateTasksSlice } from '@/modules/userTasks/hooks/use-hydrate-tasks-slice';
import { ORDER_BY_LABELS } from '@/modules/userTasks/hooks/use-task-list-view-options';
import { useTaskListHotkeys } from '@/modules/userTasks/hooks/use-task-list-hotkeys';
import { hasTaskOutput } from '@/modules/agentCanvas/utils/agenda-right-slot-state';
import { makeTaskFilter, endOfToday } from '@/modules/userTasks/utils/task-filters';
import { useExecuteTaskNow } from '@/modules/userTasks/hooks/use-execute-task-now';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMetaKeyHeld } from '@/modules/userTasks/hooks/use-meta-key-held';
import { taskGroupIcon } from '@/modules/userTasks/utils/task-group-icons';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useDayKey } from '@/modules/userTasks/hooks/use-day-boundary';
import { TaskKanbanColumn, HiddenColumnRow } from './TaskKanbanColumn';
import { DatePickerDialog } from '@/components/ui/date-picker-dialog';
import type { ConversationTaskActionData } from '@/modules/crm/types';
import { TaskKanbanCard, type TaskCardTask } from './TaskKanbanCard';
import { compareTasks } from '@/modules/userTasks/utils/task-order';
import { CEDAR_COLORS } from '@/components/ui/SexyColourPicker';
import { useTodoTasks, useCedarStore } from '@/modules/store';
import { TaskOverflowCleanup } from './TaskOverflowCleanup';
import { useTRPC } from '@/providers/query-provider';
import { ChevronDown, Plus } from 'lucide-react';
import { NewTaskDialog } from './NewTaskDialog';
import { useQueryState } from 'nuqs';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

/** A rendered board column, whatever the tasks are split by. */
export interface TaskColumn {
  key=[redacted];
  label: string;
  color: string | null;
  icon: string | null;
  tasks: TaskCardTask[];
  droppableId: string;
  /** A card may be dropped onto this column, re-filing it into the column's group. */
  canDrop: boolean;
  /** A card in this column may be picked up at all. Split from `canDrop` because Upcoming both
   *  accepts drops and releases its own cards, and because a due/channel lane releases cards for
   *  vertical reordering while accepting no re-file. */
  canDrag: boolean;
  /**
   * The ids this lane's `SortableContext` sorts, in render order.
   *
   * Carried on the column so it has a STABLE identity. Building it inline in JSX made a fresh
   * array on every render; `SortableContext` memoises on that identity, so it re-registered every
   * time, dnd-kit re-measured its droppables, `measureRect` called setState, and the render loop
   * never terminated — "Maximum update depth exceeded", thrown from inside DndContext.
   */
  sortableIds?: string[];
}

/** Columns for the "Due date" grouping, in order. */
const DUE_COLUMNS: { key=[redacted]; label: string; icon: string }[] = [
  { key=[redacted], label: 'Overdue', icon: 'Zap' },
  { key=[redacted], label: 'Today', icon: 'CalendarCheck' },
  { key=[redacted], label: 'This week', icon: 'CalendarClock' },
  { key=[redacted], label: 'Later', icon: 'CalendarClock' },
  { key=[redacted], label: 'No date', icon: 'ListTodo' },
];

/**
 * Columns for the "Channel" grouping, in order — one per TASK_OUTPUT_KINDS value, plus the
 * Undecided column that holds tasks whose output has not been chosen yet. Every kind gets a
 * column so bucketing can never silently drop a task off the board.
 */
const CHANNEL_COLUMNS: { key=[redacted]; label: string }[] = [
  ...TASK_OUTPUT_KINDS.map((kind) => ({ key=[redacted], label: TASK_OUTPUT_KIND_LABELS[kind] })),
  { key=[redacted], label: 'Undecided' },
];

/**
 * The optional Done lane's key. Not one of `DUE_COLUMNS` / `CHANNEL_COLUMNS` / a group id: it is
 * appended to whichever axis the board is on rather than being an axis of its own.
 */
export const DONE_KEY=[redacted];

/** How many cards a column renders before the tail collapses into the cleanup button. */
export const COLUMN_VISIBLE_LIMIT = 30;

/** The swatches the inline header editor offers — the same palette every other Cedar picker uses. */
const GROUP_COLOR_SWATCHES = CEDAR_COLORS.dark;

/**
 * Does this column stay on the board even with zero tasks?
 *
 * Almost never: an empty lane collapses into the "Hidden columns" rail, so the board proper is the
 * lanes with real work and an empty one is a row you can still drop onto. That holds for every axis
 * — due, channel and group alike.
 *
 * The single exemption is the lane you JUST created. `Add column` posts a group that by definition
 * has no tasks, and collapsing it on arrival made creating a column look like it had silently
 * failed. So the board keeps that one id on screen until you leave the board; every other empty
 * group lane — including one you emptied by finishing its last task — collapses like the rest.
 *
 * (Group lanes were briefly exempted wholesale for that reason, which, since the board columns by
 * group by default, retired the rail in practice. This narrows the exemption to what it was for.)
 */
export function keepsEmptyColumn(
  columnBy: TaskColumnBy,
  key=[redacted],
  justCreatedGroupId?: string | null,
): boolean {
  if (columnBy !== 'group' || key === UPCOMING_KEY) return false;
  return !!justCreatedGroupId && key === justCreatedGroupId;
}

/**
 * The board as it looks MID-DRAG, with the dragged card moved to the slot the pointer resolved.
 *
 * This is what makes a lane's cards part around an incoming card: they move because the board
 * RENDERS them in a different order, not because anything animates them. dnd-kit is explicitly
 * declined the job (see `noSortingStrategy`) — a second opinion about where the cards go is a
 * second opinion about where the card will land, and the two disagreed.
 *
 * Pure, and shared by the two places that must never disagree: the render, and the drop. The drop
 * plans against this arrangement, so what gets written is by construction what was on screen.
 *
 * Display-only in itself: `placement` is drag state, nothing is written until the drop, and a
 * cancelled drag just clears it.
 */
export function arrangeForDrag(
  columns: readonly TaskColumn[],
  draggingId: string | null,
  placement: { columnKey=[redacted]; index: number } | null,
): TaskColumn[] {
  const card =
    draggingId && placement
      ? columns.flatMap((c) => c.tasks).find((t) => t.id === draggingId)
      : undefined;
  if (!card || !placement) return columns.map((col) => ({ ...col }));

  return columns.map((col) => {
    const without = col.tasks.filter((t) => t.id !== card.id);
    if (col.key !== placement.columnKey) return { ...col, tasks: without };
    const at = Math.min(Math.max(placement.index, 0), without.length);
    return { ...col, tasks: [...without.slice(0, at), card, ...without.slice(at)] };
  });
}

interface ColumnHeaderEditorProps {
  name: string;
  color: string | null;
  onCancel: () => void;
  onSave: (patch: { name?: string; color?: string }) => void;
}

/**
 * Rename / recolour a group lane in place, from the board it lives on.
 *
 * Sits inside the column body under its header rather than floating over it, so the swatches never
 * cover the neighbouring lanes. Only the fields visible on a column header are editable here — the
 * routing criteria, overdue policy and agent visibility live on /tasks/groups, since none of them
 * are things you can see on the board and so none of them are things you'd come here to change.
 */
function ColumnHeaderEditor({ name, color, onCancel, onSave }: ColumnHeaderEditorProps) {
  const [draft, setDraft] = useState(name);

  const commit = (patch: { name?: string; color?: string }) => {
    const trimmed = draft.trim();
    // An emptied name is a slip, not an instruction — a group must always be nameable on the
    // board, so fall back to what it was rather than writing a blank header.
    onSave({ ...(trimmed && trimmed !== name ? { name: trimmed } : {}), ...patch });
  };

  return (
    <div className="bg-muted/40 mb-1 flex flex-col gap-2 rounded-md p-2">
      <input
        autoFocus
        aria-label="Column name"
        value={draft}
        onChange={(e) => setDraft(e.target.value)}
        onKeyDown={(e) => {
          if (e.key === 'Escape') {
            e.preventDefault();
            onCancel();
            return;
          }
          if (e.key !== 'Enter') return;
          e.preventDefault();
          commit({});
        }}
        onBlur={() => commit({})}
        className="focus:ring-primary/30 rounded bg-transparent px-1 py-0.5 text-sm font-medium outline-none focus:ring-1"
      />
      <div className="flex flex-wrap gap-1">
        {GROUP_COLOR_SWATCHES.map((swatch) => (
          <button
            key=[redacted]
            type="button"
            aria-label={`Set column colour ${swatch}`}
            // onMouseDown, not onClick: the name input's onBlur fires first on click and would
            // commit-and-close before the swatch ever registered.
            onMouseDown={(e) => {
              e.preventDefault();
              commit({ color: swatch });
            }}
            style={{ backgroundColor: swatch }}
            className={cn(
              'size-4 shrink-0 cursor-pointer rounded-full transition-transform hover:scale-110',
              color === swatch && 'ring-foreground/50 ring-2 ring-offset-1',
            )}
          />
        ))}
      </div>
    </div>
  );
}

function dueTime(t: { dueDate?: string | Date | null }): number {
  return t.dueDate ? new Date(t.dueDate).getTime() : 0;
}

/** Which "Due date" column a task falls into. Mirrors the List view's due buckets. */
function dueColumnKey(dueDate?: string | Date | null): string {
  if (!dueDate) return 'none';
  const d = new Date(dueDate).getTime();
  const now = new Date();
  const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
  const endToday = startToday + 24 * 60 * 60 * 1000 - 1;
  const endWeek = endToday + 6 * 24 * 60 * 60 * 1000;
  if (d < startToday) return 'overdue';
  if (d <= endToday) return 'today';
  if (d <= endWeek) return 'week';
  return 'later';
}

export function TaskKanbanBoard() {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const openTaskInExecutionMode = useOpenTaskInExecutionMode();
  const executeTaskNow = useExecuteTaskNow();
  // The task currently opened into — highlights its card as active. In the URL so the highlight
  // survives a refresh and stays in sync with the sidebar accordion (same param).
  const [selectedTaskId] = useQueryState('task', { history: 'push' });
  const { optimisticCompleteTask, optimisticSnoozeTask, optimisticDeleteTask } =
    useOptimisticTaskActions();

  // Snooze opens the same natural-language date picker email snooze uses; the chosen task waits
  // here until a date is picked.
  const [snoozeTaskId, setSnoozeTaskId] = useState<string | null>(null);
  // A card dropped on Upcoming, waiting for a date. Nothing is written until one is picked —
  // "upcoming" means "due later", and there is no later until the user says which.
  const [pendingUpcomingTaskId, setPendingUpcomingTaskId] = useState<string | null>(null);

  // The card the cursor is over — the target for the e/s/w/x hotkeys, falling back to the open task.
  const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);

  // The column currently showing the inline "new task" card (its key), or null.
  const [addingColumnKey, setAddingColumnKey] = useState<string | null>(null);

  // The column the cursor is over — the target for the `c` (new task) hotkey.
  const [hoveredColumnKey, setHoveredColumnKey] = useState<string | null>(null);

  // The group column whose header is being renamed/recoloured in place (its group id), or null.
  const [editingColumnKey, setEditingColumnKey] = useState<string | null>(null);

  // The end-of-board "add column" input's draft name — null while the control sits at rest.
  const [newColumnName, setNewColumnName] = useState<string | null>(null);

  // The lane created from this board, held on screen while empty so "Add column" visibly worked.
  // Component state, so it lasts exactly as long as the board is mounted: come back later and the
  // lane is just another empty one, which is when the rail should have it. See `keepsEmptyColumn`.
  const [justCreatedGroupId, setJustCreatedGroupId] = useState<string | null>(null);

  // Bulk-selection actions (x / shift+x). Read as actions; current selection/anchor are read via
  // getState() at call time to avoid stale closures (mirrors mail's range-select).
  const toggleTaskSelection = useCedarStore((s) => s.toggleTaskSelection);
  const setTaskSelection = useCedarStore((s) => s.setTaskSelection);
  const setTaskSelectionAnchorId = useCedarStore((s) => s.setTaskSelectionAnchorId);
  const clearTaskSelection = useCedarStore((s) => s.clearTaskSelection);
  // The board renders from the slice, so a drag has to land there to be seen — the mutation only
  // makes it durable.
  const updateTask = useCedarStore((s) => s.updateTask);

  const { data: groupsData } = useQuery(trpc.taskGroups.listGroups.queryOptions(listGroupsInput()));
  const { data: tasksData } = useQuery(
    trpc.userTasks.listUserTasks.queryOptions({
      status: 'todo',
      withConversation: true,
      limit: 500,
      sortDueDate: 'asc',
    }),
  );

  const moveTaskToGroup = useMutation(trpc.taskGroups.moveTaskToGroup.mutationOptions());

  const groupsQueryKey=[redacted];

  // Rename / recolour, applied to the cached list first so the column header changes under the
  // cursor rather than after a round trip; the invalidate then reconciles with the server's row.
  const updateGroup = useMutation(
    trpc.taskGroups.updateGroup.mutationOptions({
      onMutate: ({ groupId, name, color }) => {
        queryClient.setQueriesData({ queryKey=[redacted] }, (cached: unknown) =>
          patchCachedGroup(cached, groupId, { name, color }),
        );
      },
      onError: (err) => toast.error(`Couldn't rename the column: ${err.message}`),
      // onSettled, NOT onSuccess: onMutate has already written the new name into the shared
      // cache, so reconciling only on success leaves a FAILED edit on screen — the header
      // keeps showing a name the server never stored, until some unrelated refetch. Every
      // other optimistic group write (the order popover, /tasks/groups) settles the same way.
      onSettled: () => void queryClient.invalidateQueries({ queryKey=[redacted] }),
    }),
  );

  // A new lane. It lands empty, so remember its id — `keepsEmptyColumn` reads it to hold this one
  // lane on the board rather than letting it collapse straight into the rail it was just created
  // beside. Only the latest: creating a second column lets the first one settle.
  const createGroup = useMutation(
    trpc.taskGroups.createGroup.mutationOptions({
      onSuccess: (data) => {
        setNewColumnName(null);
        setJustCreatedGroupId(data.group.id);
        void queryClient.invalidateQueries({ queryKey=[redacted] });
      },
    }),
  );

  // The query above is ONLY the hydration feed: it flows into the slice here and is never rendered
  // from directly. The board renders SOLELY from the slice (useTodoTasks), so optimistic
  // complete/snooze/delete — which mutate the slice — take effect instantly, no refetch needed.
  useHydrateTasksSlice(tasksData?.tasks);

  const [draggingId, setDraggingId] = useState<string | null>(null);
  // Held mid-drag, not read off the pointer event: you decide you want to place a card by hand
  // AFTER you have picked it up and seen the lane. See useMetaKeyHeld.
  const metaHeld = useMetaKeyHeld();
  // The lane the drag started in, and where the board is currently showing the card. Both are
  // needed once the card moves between lanes mid-drag: the first says whether this is a re-file,
  // the second is what the cards part around.
  const [originColumnKey, setOriginColumnKey] = useState<string | null>(null);
  // The lane under the cursor. Tracked for every drag, because the lane cover is the feedback for
  // the UNMODIFIED gesture — the one where nothing else on screen changes.
  const [overColumnKey, setOverColumnKey] = useState<string | null>(null);
  // Where the card is being shown mid-drag. Only ever set while the modifier is held: without it
  // the board must not move anything at all, which is the whole distinction between the gestures.
  const [dragPlacement, setDragPlacement] = useState<{ columnKey=[redacted]; index: number } | null>(
    null,
  );
  const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } }));

  const {
    columnBy,
    orderBy,
    filterGroups,
    filterChannels,
    filterStatuses,
    filterCrm,
    filterTypes,
    collapseEmpty,
    showDone,
  } = useTaskListViewOptions();

  // Changes at local midnight, so the due-date bucketing below re-derives instead of holding the
  // boundary the tab was opened with.
  const dayKey=[redacted];

  const tasks = useTodoTasks() as unknown as TaskCardTask[];

  /**
   * The Done column's window: everything completed since the start of YESTERDAY.
   *
   * Keyed on `dayKey` so it re-derives at local midnight — a tab left open overnight would
   * otherwise keep asking for the window it was opened with, and yesterday's column would still
   * be showing the day before that.
   */
  const completedAfter = useMemo(() => {
    const start = new Date();
    start.setHours(0, 0, 0, 0);
    start.setDate(start.getDate() - 1);
    return start.toISOString();
  }, [dayKey]);

  // Only fetched when the column is actually on — a done set is unbounded in a way the todo set
  // is not, so this stays off the wire for everyone who never turns the switch on.
  const { data: doneData } = useQuery({
    ...trpc.userTasks.listUserTasks.queryOptions({
      status: 'done',
      withConversation: true,
      limit: 200,
      completedAfter,
    }),
    enabled: showDone,
  });

  // Sorted newest-completed-first: the Done column answers "what did I just finish", so the most
  // recent belongs on top. It does not use `compareTasks` — `sortOrder` describes a card's place
  // among OPEN work, and carries no meaning once the task is closed.
  const doneTasks = useMemo<TaskCardTask[]>(() => {
    const rows = (doneData?.tasks ?? []) as unknown as TaskCardTask[];
    return [...rows].sort(
      (a, b) => new Date(b.completedAt ?? 0).getTime() - new Date(a.completedAt ?? 0).getTime(),
    );
  }, [doneData]);

  // Toolbar filters. `hideFuture` is deliberately not applied — future-due tasks belong in the
  // board's own Upcoming column rather than being dropped.
  const filteredTasks = useMemo(() => {
    const passes = makeTaskFilter({
      filterGroups,
      filterChannels,
      filterStatuses,
      filterCrm,
      filterTypes,
    });
    return tasks.filter(passes);
  }, [tasks, filterGroups, filterChannels, filterStatuses, filterCrm, filterTypes, dayKey]);

  const columns = useMemo<TaskColumn[]>(() => {
    const bucketize = (keyOf: (t: TaskCardTask) => string) => {
      const map = new Map<string, TaskCardTask[]>();
      for (const t of filteredTasks) {
        const bucket = map.get(keyOf(t)) ?? [];
        bucket.push(t);
        map.set(keyOf(t), bucket);
      }
      // The board's one order, stored per card. The toolbar's Ordering re-seeds it rather than
      // choosing a comparator, so a drag works whichever ordering you picked.
      const compare = compareTasks();
      for (const bucket of map.values()) bucket.sort(compare);
      return map;
    };

    if (columnBy === 'due') {
      const map = bucketize((t) => dueColumnKey(t.dueDate));
      return DUE_COLUMNS.map((c) => ({
        key=[redacted],
        label: c.label,
        color: null,
        icon: c.icon,
        tasks: map.get(c.key) ?? [],
        droppableId: c.key,
        // A due lane isn't a group, so a drop can't re-file into it. Cards still lift under
        // manual ordering — reordering within a lane is a valid gesture on any axis.
        // A due lane isn't a group, so a drop can't re-file into it — but cards still lift, and
        // a vertical drop still places them. Position is a property of the task, not the lane.
        canDrop: false,
        canDrag: true,
      }));
    }

    if (columnBy === 'channel') {
      const map = bucketize((t) => t.taskOutput?.kind ?? UNDECIDED_OUTPUT_KEY);
      return CHANNEL_COLUMNS.map((c) => ({
        key=[redacted],
        label: c.label,
        color: null,
        icon: null,
        tasks: map.get(c.key) ?? [],
        droppableId: c.key,
        canDrop: false,
        canDrag: true,
      }));
    }

    // columnBy === 'group' (default): a column per task group (Misc + Upcoming), drag re-files.
    const cutoff = endOfToday();
    const byGroup = bucketize((t) =>
      t.dueDate && dueTime(t) > cutoff
        ? UPCOMING_KEY
        : ((t as { taskGroupId?: string | null }).taskGroupId ?? MISC_KEY),
    );
    const groups = [...(groupsData?.groups ?? [])].sort((a, b) => a.position - b.position);
    const groupColumns: TaskColumn[] = groups.map((g) => {
      const key=[redacted] || !g.id ? MISC_KEY=[redacted];
      return {
        key,
        label: g.name,
        color: g.color,
        icon: g.icon ?? null,
        tasks: byGroup.get(key) ?? [],
        droppableId: key,
        canDrop: true,
        canDrag: true,
      };
    });
    return [
      ...groupColumns,
      {
        key=[redacted],
        label: 'Upcoming',
        color: null,
        icon: 'CalendarClock',
        tasks: byGroup.get(UPCOMING_KEY) ?? [],
        droppableId: UPCOMING_KEY,
        // Dropping here means RESCHEDULING, not re-grouping: the drop opens the date picker and
        // writes nothing until a date is chosen. Membership of this column derives entirely from
        // `dueDate`, so the picked date is the whole move. See `handleDragEnd`.
        canDrop: true,
        canDrag: true,
      },
    ];
    // `dayKey` is not read in here — `endOfToday()` and `dueColumnKey()` read the clock directly.
    // It is in the dependency array so they are read AGAIN when the day rolls over: without it
    // the boundary stays on the day the tab was opened, and after midnight everything due today
    // buckets as Upcoming. See use-day-boundary.ts.
  }, [columnBy, filteredTasks, groupsData, dayKey]);

  /**
   * The Done column, appended to whatever axis the board is on — done work is not a fourth way of
   * splitting the open tasks, it is a lane alongside them, so it rides past `columnBy` untouched.
   *
   * `canDrag: false`: a completed card is a record of something that happened, and dragging it
   * back would have to mean "un-complete", which the card's own control already says more clearly
   * than a gesture across the board would.
   */
  const doneColumn = useMemo<TaskColumn>(
    () => ({
      key=[redacted],
      label: 'Done',
      color: null,
      icon: 'CircleCheck',
      tasks: doneTasks,
      droppableId: DONE_KEY,
      canDrop: true,
      canDrag: false,
    }),
    [doneTasks],
  );

  const allColumns = useMemo<TaskColumn[]>(
    () => (showDone ? [...columns, doneColumn] : columns),
    [columns, showDone, doneColumn],
  );

  /** The arrangement on screen right now — see `arrangeForDrag`, plus each lane's sortable ids. */
  const displayColumns = useMemo<TaskColumn[]>(
    () =>
      arrangeForDrag(allColumns, draggingId, dragPlacement).map((col) => ({
        ...col,
        sortableIds: col.canDrag
          ? col.tasks.slice(0, COLUMN_VISIBLE_LIMIT).map((t) => t.id)
          : undefined,
      })),
    [allColumns, draggingId, dragPlacement],
  );

  // Empty columns collapse into the right-hand "Hidden columns" rail (a task can still be filed
  // into one by dropping onto its row), so the board proper only shows lanes with real work — bar
  // the one lane `keepsEmptyColumn` exempts, the group you just added.
  //
  // Only when `collapseEmpty` is ON, which it is not by default. An empty lane is still a drop
  // target — filing the first task into a group is precisely the moment that group has nothing in
  // it — and a lane folded into the rail is one you have to find before you can drop on it. So the
  // default board shows every lane and the rail stays empty until you ask for it.
  //
  // Membership is decided on the PRE-DRAG columns, so a lane cannot collapse out from under the
  // card you are dragging out of it (nor appear because you hovered an empty one) — only the
  // contents come from the live arrangement.
  const visibleKeys = useMemo(
    () =>
      new Set(
        allColumns
          .filter(
            (c) =>
              !collapseEmpty ||
              c.key === DONE_KEY ||
              c.tasks.length > 0 ||
              keepsEmptyColumn(columnBy, c.key, justCreatedGroupId),
          )
          .map((c) => c.key),
      ),
    [allColumns, columnBy, justCreatedGroupId, collapseEmpty],
  );
  const visibleColumns = useMemo(
    () => displayColumns.filter((c) => visibleKeys.has(c.key)),
    [displayColumns, visibleKeys],
  );
  const hiddenColumns = useMemo(
    () => displayColumns.filter((c) => !visibleKeys.has(c.key)),
    [displayColumns, visibleKeys],
  );

  // The flat visible order (columns left-to-right, each due-soonest first) that shift+x ranges over
  // — so a range can cross column boundaries, including into Misc. Held in a ref the hotkey handler
  // reads at call time.
  const orderedIds = useMemo(
    () => visibleColumns.flatMap((c) => c.tasks.map((t) => t.id)),
    [visibleColumns],
  );
  const orderedIdsRef = useRef<string[]>([]);
  orderedIdsRef.current = orderedIds;

  useTaskListHotkeys({
    getTargetId: () => hoveredTaskId ?? selectedTaskId,
    onCreate: () => {
      // Open the composer in the hovered column, falling back to the first visible one.
      const hovered = visibleColumns.find((c) => c.key === hoveredColumnKey);
      const target = hovered ?? visibleColumns[0];
      if (target) setAddingColumnKey(target.key);
    },
    onComplete: (id) => void optimisticCompleteTask(id, 'checked-off'),
    onSnooze: (id) => setSnoozeTaskId(id),
    onDelete: (id) => void optimisticDeleteTask(id),
    onToggleSelect: (id) => {
      toggleTaskSelection(id);
      setTaskSelectionAnchorId(id);
    },
    onRangeSelect: (id) => {
      const ids = orderedIdsRef.current;
      const { taskSelectionAnchorId, taskSelection } = useCedarStore.getState();
      const anchor = taskSelectionAnchorId ?? id;
      const ai = ids.indexOf(anchor);
      const ti = ids.indexOf(id);
      // Fall back to a plain toggle if either endpoint isn't on the board (e.g. a filtered lane).
      if (ai === -1 || ti === -1) {
        toggleTaskSelection(id);
        setTaskSelectionAnchorId(id);
        return;
      }
      const [start, end] = ai <= ti ? [ai, ti] : [ti, ai];
      setTaskSelection([...taskSelection, ...ids.slice(start, end + 1)]);
    },
  });

  const activeTask = useMemo(
    () => (draggingId ? (tasks.find((t) => t.id === draggingId) ?? null) : null),
    [draggingId, tasks],
  );

  /**
   * One function per action for the WHOLE board, rather than a closure per card.
   *
   * `TaskKanbanCard` is `memo`'d, and a callback built inline in the map is a new prop identity on
   * every render — so the memo never bailed out and every card on the board re-rendered whenever
   * any board state changed. That is fine at rest and ruinous mid-drag, where the state driving the
   * arrangement changes on every frame: several hundred cards, each re-running `useSortable` and
   * its subscriptions, between the cursor moving and the overlay being drawn in its new place.
   *
   * `onExecute` is the reason `TaskKanbanCard` takes the task rather than closing over it: the
   * per-card decision is only WHETHER to offer it, which is a boolean, so every card that offers it
   * can share one function.
   */
  const handleToggleComplete = useCallback(
    (id: string) => void optimisticCompleteTask(id, 'checked-off'),
    [optimisticCompleteTask],
  );
  const handleDelete = useCallback(
    (id: string) => void optimisticDeleteTask(id),
    [optimisticDeleteTask],
  );
  const handleOpen = useCallback(
    (task: TaskCardTask) => {
      // Opening a single task exits selection mode (mirrors conversationsSlice clearing its
      // selection when one conversation becomes active).
      clearTaskSelection();
      openTaskInExecutionMode(task);
    },
    [clearTaskSelection, openTaskInExecutionMode],
  );
  const handleExecute = useCallback(
    (task: TaskCardTask) =>
      void executeTaskNow({
        taskId: task.id,
        conversationId: task.conversationId,
        description: task.description || 'Untitled task',
      }),
    [executeTaskNow],
  );

  // The PRE-DRAG columns, deliberately. Reading the live arrangement here fed the placement
  // computation its own output: each hover measured a list that already had the card moved into
  // it, so the index walked and the cards jittered. Logic reads `columns`; only rendering reads
  // `displayColumns`.
  const columnsRef = useRef<TaskColumn[]>([]);
  columnsRef.current = columns;

  /**
   * Is this drag placing the card, or just filing it?
   *
   * WITHIN the lane it came from, always placing — there is nothing else a drag inside one lane
   * could mean, so the cards part immediately and no modifier is involved. Leave that lane and it
   * stops: another lane's default is to take the card and let the ordering decide where it sits,
   * and the modifier is how you say "place it there too".
   *
   * So the modifier only ever asks a question that is actually open. Holding it inside the origin
   * lane changes nothing, because that lane is already sorting.
   */
  const overIsOrigin = overColumnKey !== null && overColumnKey === originColumnKey;
  const reordering = draggingId !== null && (overIsOrigin || metaHeld);

  const dropHint = useMemo(
    () => ({
      title: `Board ordered by ${ORDER_BY_LABELS[orderBy]}`,
      hint: 'Hold ⌘ to also change position',
    }),
    [orderBy],
  );

  const handleDragStart = (event: DragStartEvent) => {
    const id = String(event.active.id);
    setDraggingId(id);
    const from = columns.find((c) => c.tasks.some((t) => t.id === id));
    pendingPlacementRef.current = null;
    cancelledRef.current = false;
    measureLaneRects();
    laneGeometryRef.current = from ? measureLane(from.key, id) : null;
    setOriginColumnKey(from?.key ?? null);
    overLaneRef.current = from?.key ?? null;
    setOverColumnKey(from?.key ?? null);
    // Seeded at the card's own slot. The origin lane sorts from the first frame, and the card
    // vacates its space — without a placement to hold that space the lane would jump closed the
    // instant you picked anything up.
    const seeded = from
      ? { columnKey=[redacted], index: from.tasks.findIndex((t) => t.id === id) }
      : null;
    appliedPlacementRef.current = seeded;
    setDragPlacement(seeded);
  };

  const endDrag = () => {
    pendingPlacementRef.current = null;
    appliedPlacementRef.current = null;
    overLaneRef.current = null;
    cancelledRef.current = false;
    laneGeometryRef.current = null;
    setDraggingId(null);
    setOriginColumnKey(null);
    setOverColumnKey(null);
    setDragPlacement(null);
  };

  /**
   * The most recent placement the cursor resolved to, whether or not it was applied.
   *
   * The pointer listener records this on every move. Pressing the modifier while the pointer is
   * stationary produces no move at all, so without a remembered resolution the lane would sit
   * there refusing to part until you jiggled the mouse.
   */
  const pendingPlacementRef = useRef<{ columnKey=[redacted]; index: number } | null>(null);

  /**
   * The drag's actual state: the lane the pointer is in, and the placement being SHOWN (null when
   * the card is merely being filed into a lane and nothing inside it moves).
   *
   * Duplicated as refs alongside `overColumnKey` / `dragPlacement` because those are React state,
   * and state is only true as of the last committed render. The drop must not depend on whether a
   * frame happened to run between the final pointer event and the release — that is a coin flip
   * that writes a different position than the one on screen. These are written synchronously by
   * the pointer listener; the state is what the render is throttled to.
   */
  const overLaneRef = useRef<string | null>(null);
  const appliedPlacementRef = useRef<{ columnKey=[redacted]; index: number } | null>(null);

  /** Escape was pressed: the eventual pointerup must write nothing. */
  const cancelledRef = useRef(false);

  /**
   * The vertical midpoints of a lane's cards, measured when the pointer ENTERED that lane.
   *
   * Held fixed for as long as the drag stays in the lane, which is what makes the insertion slot
   * stable. Measuring live instead means the gap that just opened moves the very boundaries that
   * decide where the gap goes: insert at N, the cards shift, the cursor is now over a different
   * card, the slot becomes N±1, the cards shift back. The pointer never moves and the gap flickers
   * between two slots.
   *
   * Re-measured only on entering a different lane, where there is no gap open yet.
   */
  const laneGeometryRef = useRef<{ laneKey=[redacted]; mids: number[] } | null>(null);

  /** Every lane's rect, captured at drag start. See `measureLaneRects`. */
  const laneRectsRef = useRef<{ key=[redacted]; rect: DOMRect }[]>([]);

  /**
   * Capture the lane rects — once per drag, not once per pointer event.
   *
   * Hit-testing straight off the DOM meant a `querySelectorAll` plus a `getBoundingClientRect` per
   * lane at pointer-event rate, and a rect read is a forced synchronous layout — landing on the
   * very frames the board is re-rendering its cards. The overlay is positioned by React, so
   * whatever this handler spends is felt directly as distance between the cursor and the card.
   *
   * Cacheable precisely because lanes hold still: their CONTENTS move, they do not. Re-taken when
   * something that can actually move them happens — the board scrolling, or the window resizing.
   */
  const measureLaneRects = () => {
    laneRectsRef.current = [...document.querySelectorAll('[data-task-lane]')].map((lane) => ({
      key=[redacted]data-task-lane') ?? '',
      rect: lane.getBoundingClientRect(),
    }));
  };

  /**
   * Which lane the pointer is inside, by hit-testing the captured lane rects.
   *
   * Lanes are the one thing on this board whose geometry a drag cannot disturb: they keep their
   * size and position while only their CONTENTS move. That makes the pointer's lane the single
   * stable fact available mid-drag, and the reason this is not read from dnd-kit's `over`.
   */
  const laneAtPoint = (x: number, y: number): string | null => {
    for (const { key, rect } of laneRectsRef.current) {
      if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return key;
    }
    return null;
  };

  /** Card midpoints for a lane, straight from the DOM, ignoring the card being dragged. */
  const measureLane = (laneKey=[redacted], activeId: string) => {
    const lane = document.querySelector(`[data-task-lane="${CSS.escape(laneKey)}"]`);
    if (!lane) return null;
    const mids = [...lane.querySelectorAll('[data-task-card]')]
      .filter((el) => el.getAttribute('data-task-card') !== activeId)
      .map((el) => {
        const r = el.getBoundingClientRect();
        return r.top + r.height / 2;
      });
    return { laneKey, mids };
  };

  /**
   * Everything about the drop, driven by the pointer.
   *
   * dnd-kit is used to START the drag and to render the overlay; it is deliberately NOT consulted
   * for where the card is going. Its collision detection resolves against droppables that this
   * component's own output moves — including the dragged card itself, whose id then looks up to
   * the lane it CAME from — and that fed the lane back and forth between origin and destination
   * regardless of how far apart they were.
   *
   * The pointer has no such problem. Its lane comes from hit-testing lane rects, which a drag
   * cannot move; its slot comes from card midpoints captured on entering the lane, before any gap
   * is open. Neither input can be perturbed by what this calculation renders.
   */
  useEffect(() => {
    if (!draggingId) return;

    let frame = 0;

    /**
     * Publish the resolved drag to React — the ONLY part that is throttled.
     *
     * The pointer fires faster than the screen redraws, and a render here re-arranges every card
     * on the board. Coalescing to one per animation frame is what keeps the arrangement level with
     * the cursor rather than a queue of frames behind it. The decision itself stays synchronous
     * below, so the drop is never reading a position the pointer has already left.
     */
    const paint = () => {
      frame = 0;
      if (cancelledRef.current) return;
      const laneKey=[redacted];
      if (laneKey) setOverColumnKey((prev) => (prev === laneKey ? prev : laneKey));
      const applied = appliedPlacementRef.current;
      setDragPlacement((prev) => {
        if (!applied) return null;
        return prev && prev.columnKey === applied.columnKey && prev.index === applied.index
          ? prev
          : applied;
      });
    };

    const onPointerMove = (e: PointerEvent) => {
      // Abandoned: the pointer is still down, but this drag is over.
      if (cancelledRef.current) return;
      const laneKey=[redacted], e.clientY);
      if (!laneKey) return;

      const lane = columnsRef.current.find((c) => c.key === laneKey);
      // A lane that cannot take a drop is not a target; leave the arrangement where it was.
      if (!lane || (!lane.canDrop && laneKey !== originColumnKey)) return;

      overLaneRef.current = laneKey;
      // Re-measured only on entering a different lane, where no gap is open yet.
      if (laneGeometryRef.current?.laneKey !== laneKey) {
        laneGeometryRef.current = measureLane(laneKey, draggingId);
      }
      const geometry = laneGeometryRef.current;
      if (!geometry) return;

      const next = { columnKey=[redacted], index: insertionIndexForY(geometry.mids, e.clientY) };
      // Recorded even when not applied, so the modifier can apply it without the pointer moving.
      pendingPlacementRef.current = next;
      // Applied only where a slot is actually being aimed at: inside the origin lane, or anywhere
      // with the modifier held. Elsewhere the lane takes the card whole and nothing inside moves.
      appliedPlacementRef.current = laneKey !== originColumnKey && !metaHeld ? null : next;

      // Cheap arithmetic over cached geometry, so it runs on every event; only the render waits.
      if (frame) return;
      frame = requestAnimationFrame(paint);
    };

    // The two things that DO move a lane. Both invalidate the captured rects, so re-take them
    // rather than let the rest of the drag aim at where the lanes used to be.
    const remeasure = () => measureLaneRects();

    /**
     * Escape abandons the drag, modifier held or not.
     *
     * dnd-kit's pointer sensor has its own Escape handling, but it keys on `event.code` and only
     * from the document it attached to — so it is easy for the key never to reach it. This does
     * not try to stop dnd-kit's drag: it puts the board back and marks the gesture abandoned, and
     * `handleDragEnd` then writes nothing when the pointer is eventually released. Capture phase,
     * because a hotkey handler higher up may otherwise swallow it.
     */
    const onKeyDown = (e: KeyboardEvent) => {
      if (e.key !== 'Escape' && e.code !== 'Escape') return;
      cancelledRef.current = true;
      appliedPlacementRef.current = null;
      overLaneRef.current = null;
      setDragPlacement(null);
      setOverColumnKey(null);
    };

    window.addEventListener('pointermove', onPointerMove);
    window.addEventListener('keydown', onKeyDown, true);
    window.addEventListener('resize', remeasure);
    // Capture: the board's own horizontal scroller does not bubble a scroll event.
    window.addEventListener('scroll', remeasure, true);
    return () => {
      if (frame) cancelAnimationFrame(frame);
      window.removeEventListener('pointermove', onPointerMove);
      window.removeEventListener('keydown', onKeyDown, true);
      window.removeEventListener('resize', remeasure);
      window.removeEventListener('scroll', remeasure, true);
    };
  }, [draggingId, metaHeld, originColumnKey]);

  // Pressing or releasing the modifier mid-drag takes effect immediately, rather than waiting for
  // the pointer to move again. See pendingPlacementRef.
  useEffect(() => {
    // `cancelledRef` first: Escape clears `overColumnKey`, which re-runs this effect, which would
    // otherwise re-apply the remembered placement and put the card straight back — the board
    // refusing to un-drag while the modifier was still held.
    if (!draggingId || cancelledRef.current) return;
    if (metaHeld) {
      if (pendingPlacementRef.current) {
        appliedPlacementRef.current = pendingPlacementRef.current;
        setDragPlacement(pendingPlacementRef.current);
      }
    } else if (overColumnKey !== originColumnKey) {
      appliedPlacementRef.current = null;
      setDragPlacement(null);
    }
  }, [draggingId, metaHeld, overColumnKey, originColumnKey]);

  const handleDragEnd = () => {
    // Escape already put the board back; the release must not write anything.
    if (cancelledRef.current) {
      endDrag();
      return;
    }

    // A drop onto Done means "finish this", not "file it somewhere", so it never reaches
    // `planTaskDrop` — that function only knows how to move a card between OPEN lanes, and would
    // read the Done lane as a group to re-file into. `optimisticCompleteTask` is the same path the
    // `e` hotkey and the card's own control take, so the three cannot drift.
    if (overLaneRef.current === DONE_KEY) {
      const completedId = draggingId;
      endDrag();
      if (completedId) {
        void optimisticCompleteTask(completedId, 'checked-off');
        // Refetches the Done column too — both queries live under the same key prefix.
        void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] });
      }
      return;
    }

    // Planned by re-deriving the arrangement from the same inputs the render derives it from, so
    // the write and the screen cannot be different answers — `arrangeForDrag` is the one place
    // either of them comes from.
    //
    // From the REFS, not from `displayColumns` / `overColumnKey` / `reordering`. Those are React
    // state, so they are only true as of the last committed render, and the render is throttled to
    // one per frame: a release landing between the final pointer event and its frame would plan
    // against the position before last. `reorder` is `applied !== null` for the same reason it
    // renders that way — a placement is shown exactly when one is being aimed at.
    const applied = appliedPlacementRef.current;
    const plan = planTaskDrop({
      columns: arrangeForDrag(columnsRef.current, draggingId, applied),
      taskId: draggingId ?? '',
      targetColumnKey=[redacted],
      originColumnKey=[redacted] ?? undefined,
      columnBy,
      reorder: applied !== null,
    });

    // The gesture is over the moment it is planned. Everything below writes the plan; none of it
    // needs the drag state, and leaving it set keeps the pointer listener live — the board would
    // go on re-arranging itself under a mouse with no button held.
    endDrag();

    if (plan.kind === 'none') return;
    if (plan.kind === 'ask-upcoming') {
      setPendingUpcomingTaskId(plan.taskId);
      return;
    }

    const { taskId, groupId, sortOrder, dueDate, refiles } = plan;

    // Optimistic first, then CANCEL any in-flight listUserTasks, then write. The cancel has to sit
    // between the two: an outstanding fetch that resolves after the slice patch re-hydrates the
    // pre-drop order over it. See apps/mail/docs/wiki/task-board-data-flow.md.
    updateTask(taskId, {
      // Only a modified drop pins. An unmodified one files the card and leaves its position to
      // the board's ordering, which is the whole point of the two gestures being different.
      ...(sortOrder !== undefined ? { sortOrder, sortOrderPinned: true } : {}),
      ...(refiles ? { taskGroupId: groupId } : {}),
      ...(dueDate ? { dueDate } : {}),
    });

    void queryClient.cancelQueries({ queryKey: [['userTasks', 'listUserTasks']] });

    // `groupId`, not `taskGroupId` — the procedure's param name.
    void moveTaskToGroup
      .mutateAsync({
        taskId,
        groupId,
        ...(sortOrder !== undefined ? { sortOrder } : {}),
        ...(dueDate ? { dueDate: dueDate.toISOString() } : {}),
      })
      .then(() => {
        void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] });
        if (refiles) {
          void queryClient.invalidateQueries({ queryKey: [['taskGroups', 'listGroups']] });
        }
      })
      .catch(() => {
        toast.error('Could not move that task');
        void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] });
      });
  };

  return (
    <DndContext
      sensors={sensors}
      collisionDetection={closestCorners}
      /**
       * Measure the drop targets ONCE, before the drag, and never again while it runs.
       *
       * dnd-kit's default re-measures whenever the DOM changes, via a MutationObserver. That is
       * the loop: opening a gap moves cards, the observer fires, `measureRect` calls setState,
       * React commits, the DOM moves again. It is self-sustaining and ends in "Maximum update
       * depth exceeded".
       *
       * Nothing here needs live rects. dnd-kit resolves only WHICH LANE the pointer is over, and
       * lanes do not move during a drag — their contents do. The slot within a lane is computed
       * from the pointer against geometry this component captures itself (see laneGeometryRef),
       * precisely so that it does not depend on measurements the board's own output can disturb.
       */
      measuring={{ droppable: { strategy: MeasuringStrategy.BeforeDragging } }}
      onDragStart={handleDragStart}
      onDragCancel={endDrag}
      onDragEnd={handleDragEnd}
    >
      {/* Fills the space under the header (min-h-0 flex-1). The board scrolls *horizontally* here;
          vertical scrolling is delegated to each column so a long lane never grows the page. */}
      <div className="flex min-h-0 flex-1 gap-3 overflow-x-auto pb-3">
        {visibleColumns.map((col) => {
          const Icon = taskGroupIcon(col.icon);
          const overflow = col.tasks.slice(COLUMN_VISIBLE_LIMIT);
          // The `task_groups` row this column stands for, when it is one: Misc is virtual and
          // Upcoming / due / channel lanes aren't groups at all. It is both where a task created
          // here is filed and what the header edits — the two things that need a real group id.
          const columnGroupId =
            columnBy === 'group' && col.key !== MISC_KEY && col.key !== UPCOMING_KEY
              ? col.key=[redacted];
          // Clicking the header enters execution mode on this group — exactly what clicking its
          // first card does, so the left rail becomes this group's task list and the first task
          // opens. Group columns only: a due/channel/Upcoming lane isn't a group you can work.
          const headerTask =
            columnBy === 'group' && col.key !== UPCOMING_KEY ? col.tasks[0] : undefined;
          return (
            <TaskKanbanColumn
              key=[redacted]
              id={col.droppableId}
              label={col.label}
              count={col.tasks.length}
              canDrop={col.canDrop}
              isOver={overColumnKey === col.key && draggingId !== null}
              // EVERY column gets one. Without a SortableContext dnd-kit treats the cards as
              // plain draggables: no gap opens as you drag, and a drop has no between-cards
              // target to land on — so the card springs back and the feature looks broken. That
              // was the bug; it was gated on an ordering mode nobody was in.
              sortableIds={col.sortableIds}
              reordering={reordering}
              dropHint={dropHint}
              onAdd={col.key === UPCOMING_KEY ? undefined : () => setAddingColumnKey(col.key)}
              onOpen={
                // A real group's header is its edit affordance — the lane is the only place its
                // name and colour are visible, so that is where they are changed. Working the
                // group is unaffected: clicking its first card does exactly what the header used
                // to. Misc has no row to edit, so its header keeps opening execution mode.
                columnGroupId
                  ? () => setEditingColumnKey(columnGroupId)
                  : headerTask
                    ? () => {
                        clearTaskSelection();
                        openTaskInExecutionMode({
                          id: headerTask.id,
                          conversationId: headerTask.conversationId,
                          taskActionData: headerTask.taskActionData ?? null,
                          // Carried so a DONE Slack task does not re-seed its already-sent
                          // message — see `openTask`.
                          status: headerTask.status ?? null,
                          chatThreadId: headerTask.chatThreadId ?? null,
                        });
                      }
                    : undefined
              }
              onHover={(hovered) =>
                setHoveredColumnKey((prev) => (hovered ? col.key=[redacted] === col.key ? null : prev))
              }
              icon={
                <Icon
                  className="size-4 shrink-0"
                  style={col.color ? { color: col.color } : undefined}
                />
              }
              footer={
                overflow.length > 0 ? (
                  <TaskOverflowCleanup columnLabel={col.label} tasks={overflow} />
                ) : undefined
              }
            >
              {editingColumnKey === col.key && columnGroupId && (
                <ColumnHeaderEditor
                  name={col.label}
                  color={col.color}
                  onCancel={() => setEditingColumnKey(null)}
                  onSave={(patch) => {
                    setEditingColumnKey(null);
                    updateGroup.mutate({ groupId: columnGroupId, ...patch });
                  }}
                />
              )}
              {col.tasks.slice(0, COLUMN_VISIBLE_LIMIT).map((t) => (
                <TaskKanbanCard
                  key=[redacted]
                  task={t}
                  isActive={t.id === selectedTaskId}
                  onHover={setHoveredTaskId}
                  draggable={col.canDrag}
                  reordering={reordering}
                  onToggleComplete={handleToggleComplete}
                  onSnooze={setSnoozeTaskId}
                  onDelete={handleDelete}
                  onOpen={handleOpen}
                  onOpenConversation={openConversationFromAgenda}
                  // Nothing produced yet → offer Execute right on the card. Runs in the
                  // background (no chat panel / view switch) and attaches the draft to the task,
                  // exactly like the list row's Execute.
                  onExecute={
                    hasTaskOutput(t.taskActionData as ConversationTaskActionData | null | undefined)
                      ? undefined
                      : handleExecute
                  }
                />
              ))}
            </TaskKanbanColumn>
          );
        })}

        {/* The board's right-hand rail: Add column sitting directly above the hidden lanes, in one
            240px column rather than two side by side. They belong together — both are about the
            board's SHAPE rather than its work, and splitting them across two columns spent 480px
            of horizontal scroll on two mostly-empty rails. */}
        {(columnBy === 'group' || hiddenColumns.length > 0) && (
          <div className="flex w-[240px] shrink-0 flex-col gap-3 pt-2">
            {/* Add column — a new task group, created straight from the board. Group mode only: a
                due/channel column isn't something that can be created. */}
            {columnBy === 'group' &&
              (newColumnName === null ? (
                <button
                  type="button"
                  onClick={() => setNewColumnName('')}
                  className="text-muted-foreground hover:bg-muted/50 hover:text-foreground flex cursor-pointer items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors"
                >
                  <Plus className="size-3.5 shrink-0" />
                  Add column
                </button>
              ) : (
                <input
                  autoFocus
                  aria-label="New column name"
                  placeholder="Column name"
                  value={newColumnName}
                  disabled={createGroup.isPending}
                  onChange={(e) => setNewColumnName(e.target.value)}
                  onKeyDown={(e) => {
                    if (e.key === 'Escape') {
                      e.preventDefault();
                      setNewColumnName(null);
                      return;
                    }
                    if (e.key !== 'Enter') return;
                    e.preventDefault();
                    const trimmed = newColumnName.trim();
                    if (!trimmed) {
                      setNewColumnName(null);
                      return;
                    }
                    createGroup.mutate({ name: trimmed });
                  }}
                  className="bg-muted/50 focus:ring-primary/30 rounded-md px-2 py-1.5 text-sm outline-none focus:ring-1"
                />
              ))}

            {/* Hidden columns — empty lanes, collapsed into a rail on the right (Linear-style). */}
            {hiddenColumns.length > 0 && (
              <div className="flex flex-col gap-0.5">
                <div className="text-muted-foreground flex items-center gap-1.5 px-2 pb-1 text-xs font-medium">
                  <ChevronDown className="size-3.5 shrink-0" />
                  Hidden columns
                </div>
                {hiddenColumns.map((col) => {
                  const Icon = taskGroupIcon(col.icon);
                  return (
                    <HiddenColumnRow
                      key=[redacted]
                      id={col.droppableId}
                      label={col.label}
                      icon={
                        <Icon
                          className="size-4 shrink-0"
                          style={col.color ? { color: col.color } : undefined}
                        />
                      }
                    />
                  );
                })}
              </div>
            )}
          </div>
        )}
      </div>

      {/* Just the card. The "hold ⌘" affordance lives on the lane's cover, where the choice is
          actually being made — a second copy riding the cursor said the same thing twice. */}
      {/* `dropAnimation={null}`: the drop is written optimistically in the same commit that ends
          the drag, so the card is ALREADY in its new lane by the time the release is painted.
          dnd-kit's default is a 250ms flight of the overlay back to the rect the card was
          measured at — its OLD column — after which it vanishes and the real card appears
          somewhere else entirely. That replay of the move you just made is the delay. */}
      <DragOverlay dropAnimation={null}>
        {activeTask ? <TaskKanbanCard task={activeTask} draggable={false} isOverlay /> : null}
      </DragOverlay>

      {/* The natural-language snooze picker — same modal email snooze uses, and the same modal a
          drop onto Upcoming opens: both are the question "until when?", so both ask it the same
          way. Dismissing it writes nothing, which for the Upcoming drop is also the rollback —
          the card's column derives from `dueDate`, so an unanswered drop never moved it. */}
      <DatePickerDialog
        open={snoozeTaskId !== null || pendingUpcomingTaskId !== null}
        onOpenChange={(open) => {
          if (open) return;
          setSnoozeTaskId(null);
          setPendingUpcomingTaskId(null);
        }}
        onSelect={(date) => {
          const taskId = snoozeTaskId ?? pendingUpcomingTaskId;
          // `sortOrder` is deliberately untouched: the card keeps its place in line, in a
          // different line.
          if (taskId) void optimisticSnoozeTask(taskId, date);
          setSnoozeTaskId(null);
          setPendingUpcomingTaskId(null);
        }}
        title="Snooze until"
        placeholder="Try: tomorrow, next week, or aug 7"
      />

      {/* Creating a task is a modal, not a card in the column — see NewTaskDialog. The column
          that opened it only decides which group the task lands in. */}
      <NewTaskDialog
        open={addingColumnKey !== null}
        onOpenChange={(open) => {
          if (!open) setAddingColumnKey(null);
        }}
        taskGroupId={
          columnBy === 'group' &&
          addingColumnKey !== null &&
          addingColumnKey !== MISC_KEY &&
          addingColumnKey !== UPCOMING_KEY
            ? addingColumnKey=[redacted]
        }
        onCreated={() =>
          void queryClient.invalidateQueries({ queryKey: [['userTasks', 'listUserTasks']] })
        }
      />
    </DndContext>
  );
}