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>
);
}