From 891d0d54c1bd4d8edef62f224221170ccc752781 Mon Sep 17 00:00:00 2001 From: Alex Soffronow Pagonidis <237136924+alex-clickhouse@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:54:54 +0000 Subject: [PATCH 1/3] Add a Kanban board view for tasks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /tasks was a single column capped at max-w-3xl and centred, so on a wide screen roughly two thirds of the viewport was empty. This adds a Board view alongside it: one lane per configured status, drag to reorder or to change status, and live updates as tasks change anywhere. Board is the default at >=1280px and List below it; the choice is remembered. List view is untouched — same component, same behaviour — because a linear, searchable, paginated view is still the better tool once there are more tasks than fit on a screen. Ordering is server-authoritative. A drop resolves to "put this between A and B" and the server computes the rank, so a board a few seconds stale can't overwrite someone else's ordering with numbers derived from a lane it no longer matches. dropIntent.ts holds that translation as pure functions, mostly so the awkward case is testable: dragging *downward* within a lane has to exclude the moved card before reading its anchors, or it anchors against its own current position and lands one slot short. Moves are optimistic against a snapshot. On failure the exact prior order is restored — totals included, since those drive the lane headers — and then refetched, because a rejected move means the board was already out of date and the snapshot is only a stopgap. Live updates arrive over the task_updated broadcast, so a card moves when the agent changes a task in an unrelated session, or when a second tab does. Handling is idempotent: the same event is also echoed back to the client that caused it. Two smaller decisions worth noting. The drag sensor has a 4px activation distance so a plain click still opens the task rather than starting a drag. And the board card drops the list card's inline status `, and a card whose whole surface + * is a drag handle shouldn't also contain a control that swallows pointer + * events. Changing status here is the drag itself. + */ +function BoardCardInner({ task, onOpen }: BoardCardProps) { + const { + attributes, listeners, setNodeRef, transform, transition, isDragging, + } = useSortable({ id: task.id, data: { type: 'task', task } }); + + const tags = parseTags(task.tags); + + return ( +
onOpen(task)} + // The drag sensor has a 4px activation distance, so a plain click + // still reaches this handler and opens the task. + className={`group text-left w-full p-3 bg-surface border border-border-subtle rounded-lg + hover:border-border cursor-pointer transition-colors + ${isDragging ? 'opacity-40' : ''}`} + > +

+ {task.title} +

+ + {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {tags.length > 3 && ( + + +{tags.length - 3} + + )} +
+ )} + +
+ {task.deadline && ( + + {task.deadline} + + )} + {task.updated_at && ( + + {formatTimeAgo(task.updated_at)} + + )} + {task.source && task.source !== 'manual' && ( + {task.source} + )} + {task.source_url && ( + e.stopPropagation()} + // Not a drag target: stop the sensor claiming the pointer so + // the link is clickable rather than the start of a drag. + onPointerDown={(e) => e.stopPropagation()} + className="ml-auto text-text-faint hover:text-text-muted opacity-0 group-hover:opacity-100 transition-opacity" + aria-label="Open source link" + > + + + )} +
+
+ ); +} + +// Lanes re-render on every drag frame; without memo each card in every +// lane re-renders with them. +export const BoardCard = memo(BoardCardInner); + +/** The card rendered under the cursor mid-drag (no sortable wiring). */ +export function BoardCardOverlay({ task }: { task: Task }) { + const tags = parseTags(task.tags); + return ( +
+

+ {task.title} +

+ {tags.length > 0 && ( +
+ {tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ )} +
+ ); +} diff --git a/web/src/components/Tasks/Board/BoardColumn.tsx b/web/src/components/Tasks/Board/BoardColumn.tsx new file mode 100644 index 00000000..c8e84a93 --- /dev/null +++ b/web/src/components/Tasks/Board/BoardColumn.tsx @@ -0,0 +1,125 @@ +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { ChevronLeft, ChevronRight, Plus } from 'lucide-react'; +import type { Task, TaskStatusDef } from '../../../api/client'; +import type { Lane } from '../../../stores/taskStore'; +import { BoardCard } from './BoardCard'; + +export interface BoardColumnProps { + lane: Lane; + status: TaskStatusDef | undefined; + collapsed: boolean; + onToggleCollapse: (status: string) => void; + onCreate: (status: string) => void; + onOpenTask: (task: Task) => void; +} + +export function BoardColumn({ + lane, status, collapsed, onToggleCollapse, onCreate, onOpenTask, +}: BoardColumnProps) { + // A lane must accept a drop even with no cards in it, and an empty + // SortableContext registers no droppable of its own — hence the explicit + // one on the column body. + const { setNodeRef, isOver } = useDroppable({ + id: `lane:${lane.status}`, + data: { type: 'lane', status: lane.status }, + }); + + const label = status?.label ?? lane.status; + const color = status?.color ?? '#6b7280'; + const hidden = lane.total - lane.tasks.length; + + if (collapsed) { + return ( +
+ + + {/* Vertical rail: the label reads bottom-to-top so long status + names stay legible instead of being truncated to a glyph. */} + + {label} + + {lane.total} +
+ ); + } + + return ( +
+
+
+ +

{label}

+ {lane.total} +
+ + +
+
+ {status?.description && ( +

+ {status.description} +

+ )} +
+ +
+ t.id)} + strategy={verticalListSortingStrategy} + > + {lane.tasks.map((task) => ( + + ))} + + + {lane.tasks.length === 0 && ( +
+ {isOver ? 'Drop here' : 'No tasks'} +
+ )} + + {hidden > 0 && ( + // The lane is paginated server-side; say so rather than silently + // showing a partial column whose count doesn't match its cards. +

+ +{hidden} more — narrow the filters or use List view +

+ )} +
+
+ ); +} diff --git a/web/src/components/Tasks/Board/BoardFilterBar.tsx b/web/src/components/Tasks/Board/BoardFilterBar.tsx new file mode 100644 index 00000000..7653f64c --- /dev/null +++ b/web/src/components/Tasks/Board/BoardFilterBar.tsx @@ -0,0 +1,52 @@ +import { Tag, X } from 'lucide-react'; +import { useTaskStore } from '../../../stores/taskStore'; + +/** How many tag facets to offer before it stops being a filter bar. */ +const MAX_FACETS = 12; + +export function BoardFilterBar() { + const availableTags = useTaskStore((s) => s.availableTags); + const tagFilter = useTaskStore((s) => s.tagFilter); + const setTagFilter = useTaskStore((s) => s.setTagFilter); + + if (availableTags.length === 0) return null; + + // Ranked by count server-side; an active filter is pinned so it can + // always be switched off even if it falls outside the top slice. + const shown = availableTags.slice(0, MAX_FACETS); + const activeIsHidden = tagFilter && !shown.some((t) => t.name === tagFilter); + const facets = activeIsHidden + ? [...shown, availableTags.find((t) => t.name === tagFilter)!] + : shown; + + return ( +
+ + {facets.map((tag) => { + const active = tag.name === tagFilter; + return ( + + ); + })} + {tagFilter && ( + + )} +
+ ); +} diff --git a/web/src/components/Tasks/Board/TaskBoard.tsx b/web/src/components/Tasks/Board/TaskBoard.tsx new file mode 100644 index 00000000..c154ef35 --- /dev/null +++ b/web/src/components/Tasks/Board/TaskBoard.tsx @@ -0,0 +1,150 @@ +import { useCallback, useMemo, useState } from 'react'; +import { + DndContext, + DragOverlay, + KeyboardSensor, + PointerSensor, + closestCorners, + useSensor, + useSensors, + type DragEndEvent, + type DragStartEvent, +} from '@dnd-kit/core'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; +import type { Task } from '../../../api/client'; +import { useTaskStatusStore } from '../../../stores/taskStatusStore'; +import { useTaskStore } from '../../../stores/taskStore'; +import { BoardCardOverlay } from './BoardCard'; +import { isNoOpMove, resolveDropIntent } from './dropIntent'; +import { BoardColumn } from './BoardColumn'; + +const COLLAPSED_KEY = 'nerve_board_collapsed'; + +function readCollapsed(): string[] { + try { + const raw = localStorage.getItem(COLLAPSED_KEY); + const parsed = raw ? JSON.parse(raw) : []; + return Array.isArray(parsed) ? parsed.filter((x) => typeof x === 'string') : []; + } catch { + return []; + } +} + +function writeCollapsed(next: string[]): void { + try { + localStorage.setItem(COLLAPSED_KEY, JSON.stringify(next)); + } catch { + /* not fatal */ + } +} + +export function TaskBoard({ onOpenTask }: { onOpenTask: (task: Task) => void }) { + const lanes = useTaskStore((s) => s.lanes); + const boardLoading = useTaskStore((s) => s.boardLoading); + const boardError = useTaskStore((s) => s.boardError); + const moveTask = useTaskStore((s) => s.moveTask); + const setShowCreateDialog = useTaskStore((s) => s.setShowCreateDialog); + const statuses = useTaskStatusStore((s) => s.statuses); + + const [activeTask, setActiveTask] = useState(null); + const [collapsed, setCollapsed] = useState(readCollapsed); + + const sensors = useSensors( + // 4px of travel before a drag begins, so a plain click still reaches + // the card's onClick and opens the task instead of starting a drag. + useSensor(PointerSensor, { activationConstraint: { distance: 4 } }), + useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }), + ); + + const statusByName = useMemo( + () => new Map(statuses.map((s) => [s.name, s])), + [statuses], + ); + + const handleToggleCollapse = useCallback((status: string) => { + setCollapsed((prev) => { + const next = prev.includes(status) + ? prev.filter((s) => s !== status) + : [...prev, status]; + writeCollapsed(next); + return next; + }); + }, []); + + const handleDragStart = useCallback((event: DragStartEvent) => { + const task = event.active.data.current?.task as Task | undefined; + setActiveTask(task ?? null); + }, []); + + const handleDragEnd = useCallback((event: DragEndEvent) => { + setActiveTask(null); + const { active, over } = event; + if (!over) return; + + const activeId = String(active.id); + const overId = String(over.id); + if (activeId === overId) return; + + const intent = resolveDropIntent(lanes, activeId, overId); + if (!intent) return; + // Skip the round trip when the card was dropped back where it started. + if (isNoOpMove(lanes, activeId, intent)) return; + + void moveTask(activeId, intent); + }, [lanes, moveTask]); + + if (boardLoading) { + return
Loading board...
; + } + + if (lanes.length === 0) { + return
No statuses configured.
; + } + + return ( + <> + {boardError && ( +
+ {boardError} +
+ )} + setActiveTask(null)} + accessibility={{ + announcements: { + onDragStart: ({ active }) => `Picked up task ${active.id}.`, + onDragOver: ({ over }) => + over ? `Task is over ${String(over.id).replace('lane:', 'the ')} lane.` : '', + onDragEnd: ({ over }) => + over ? `Task dropped on ${over.id}.` : 'Task dropped, position unchanged.', + onDragCancel: () => 'Move cancelled.', + }, + }} + > +
+
+ {lanes.map((lane) => ( + setShowCreateDialog(true, status)} + onOpenTask={onOpenTask} + /> + ))} +
+
+ + + {activeTask && } + +
+ + ); +} diff --git a/web/src/components/Tasks/Board/dropIntent.test.tsx b/web/src/components/Tasks/Board/dropIntent.test.tsx new file mode 100644 index 00000000..ca4f3d5b --- /dev/null +++ b/web/src/components/Tasks/Board/dropIntent.test.tsx @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import type { Task } from '../../../api/client'; +import type { Lane } from '../../../stores/taskStore'; +import { isNoOpMove, resolveDropIntent } from './dropIntent'; + +/** + * Drop resolution: "card X landed on target Y" → the neighbour pair the + * move API expects. + * + * This is where drag-and-drop bugs actually live. The classic one is + * dragging a card *downward* within its own lane: if the moved card isn't + * excluded from the lane before computing anchors, it anchors against its + * own current position and lands one slot short of where it was dropped. + * Every direction is pinned below for that reason. + */ + +function task(id: string, status = 'pending', position = 0): Task { + return { + id, title: id, status, position, + deadline: null, source: 'manual', source_url: null, tags: '', + created_at: '2026-08-05T00:00:00Z', updated_at: '2026-08-05T00:00:00Z', + }; +} + +function lanes(): Lane[] { + return [ + { + status: 'pending', + total: 3, + tasks: [task('a', 'pending', 1024), task('b', 'pending', 2048), task('c', 'pending', 3072)], + }, + { status: 'in_progress', total: 1, tasks: [task('x', 'in_progress', 1024)] }, + { status: 'done', total: 0, tasks: [] }, + ]; +} + +describe('resolveDropIntent — within a lane', () => { + it('dropping on the card above puts the card in that slot', () => { + // c dropped onto a → c takes a's place, a is pushed down. + expect(resolveDropIntent(lanes(), 'c', 'a')).toEqual({ + status: 'pending', beforeId: null, afterId: 'a', + }); + }); + + it('dropping on a middle card anchors between its neighbours', () => { + expect(resolveDropIntent(lanes(), 'c', 'b')).toEqual({ + status: 'pending', beforeId: 'a', afterId: 'b', + }); + }); + + it('dragging downward excludes the moved card from its own anchors', () => { + // a dropped onto c. With `a` still in the list, index(c) === 2 and the + // preceding card would be `b` — correct only by accident. Excluding `a` + // first gives index 1, preceded by `b`, followed by `c`. The bug shows + // up as the card landing above `c` instead of taking its slot. + expect(resolveDropIntent(lanes(), 'a', 'c')).toEqual({ + status: 'pending', beforeId: 'b', afterId: 'c', + }); + }); + + it('dragging the top card to the middle', () => { + expect(resolveDropIntent(lanes(), 'a', 'b')).toEqual({ + status: 'pending', beforeId: null, afterId: 'b', + }); + }); + + it('dropping on the lane background appends to the end', () => { + expect(resolveDropIntent(lanes(), 'a', 'lane:pending')).toEqual({ + status: 'pending', beforeId: 'c', afterId: null, + }); + }); +}); + +describe('resolveDropIntent — across lanes', () => { + it('dropping onto a card in another lane takes its slot', () => { + expect(resolveDropIntent(lanes(), 'a', 'x')).toEqual({ + status: 'in_progress', beforeId: null, afterId: 'x', + }); + }); + + it('dropping on another lane background appends there', () => { + expect(resolveDropIntent(lanes(), 'a', 'lane:in_progress')).toEqual({ + status: 'in_progress', beforeId: 'x', afterId: null, + }); + }); + + it('dropping into an empty lane yields no anchors', () => { + expect(resolveDropIntent(lanes(), 'a', 'lane:done')).toEqual({ + status: 'done', beforeId: null, afterId: null, + }); + }); + + it('returns null for an unrecognised drop target', () => { + expect(resolveDropIntent(lanes(), 'a', 'lane:nonexistent')).toBeNull(); + expect(resolveDropIntent(lanes(), 'a', 'ghost-task')).toBeNull(); + }); +}); + +describe('isNoOpMove', () => { + it('detects a drop onto the immediate next card as a no-op', () => { + // The reachable no-op: `a` dropped onto `b`, which already sits + // directly below it, resolves to the slot `a` is already in. Without + // this check every such drag costs a round trip and bumps updated_at + // for nothing. + const intent = resolveDropIntent(lanes(), 'a', 'b'); + expect(intent).toEqual({ status: 'pending', beforeId: null, afterId: 'b' }); + expect(isNoOpMove(lanes(), 'a', intent!)).toBe(true); + }); + + it('detects a card dropped on its own origin slot', () => { + expect(isNoOpMove(lanes(), 'b', { status: 'pending', beforeId: 'a', afterId: 'c' })) + .toBe(true); + }); + + it('treats a real reorder as a change', () => { + expect(isNoOpMove(lanes(), 'c', { status: 'pending', beforeId: null, afterId: 'a' })) + .toBe(false); + }); + + it('treats a lane change as a change even at the same index', () => { + expect(isNoOpMove(lanes(), 'a', { status: 'in_progress', beforeId: null, afterId: 'x' })) + .toBe(false); + }); + + it('recognises the tail slot as a no-op for the last card', () => { + expect(isNoOpMove(lanes(), 'c', { status: 'pending', beforeId: 'b', afterId: null })) + .toBe(true); + }); +}); diff --git a/web/src/components/Tasks/Board/dropIntent.ts b/web/src/components/Tasks/Board/dropIntent.ts new file mode 100644 index 00000000..1ad8ca03 --- /dev/null +++ b/web/src/components/Tasks/Board/dropIntent.ts @@ -0,0 +1,67 @@ +import type { Lane, MoveIntent } from '../../../stores/taskStore'; + +/** + * Drag-drop geometry, kept out of the component file so it can be tested + * directly (and so the component module stays exports-components-only for + * fast refresh). + */ + +/** + * Translate "card X was dropped on target Y" into the neighbour pair the + * move API expects. + * + * The drop target is either another card or a lane's background. For a + * card, the moved item takes that card's slot and pushes it down — so the + * dropped-on card becomes `afterId`, and whatever preceded it becomes + * `beforeId`. + * + * The moved card is excluded from the lane before anchors are read. Skip + * that and a downward drag within one lane anchors against the card's own + * current position, landing it one slot short of where it was dropped. + */ +export function resolveDropIntent( + lanes: Lane[], + activeId: string, + overId: string, +): MoveIntent | null { + const laneFromOver = overId.startsWith('lane:') + ? lanes.find((l) => l.status === overId.slice('lane:'.length)) + : lanes.find((l) => l.tasks.some((t) => t.id === overId)); + if (!laneFromOver) return null; + + const others = laneFromOver.tasks.filter((t) => t.id !== activeId); + const appendToTail = (): MoveIntent => ({ + status: laneFromOver.status, + beforeId: others[others.length - 1]?.id ?? null, + afterId: null, + }); + + // Dropped on empty space in the column. + if (overId.startsWith('lane:')) return appendToTail(); + + const at = others.findIndex((t) => t.id === overId); + // The anchor card is gone from this lane (it moved while the drag was in + // flight, or it *is* the dragged card — which the caller short-circuits). + if (at === -1) return appendToTail(); + + return { + status: laneFromOver.status, + beforeId: others[at - 1]?.id ?? null, + afterId: others[at].id, + }; +} + +/** True when the intent would leave the board exactly as it is. */ +export function isNoOpMove( + lanes: Lane[], + activeId: string, + intent: MoveIntent, +): boolean { + const lane = lanes.find((l) => l.status === intent.status); + if (!lane) return false; + const at = lane.tasks.findIndex((t) => t.id === activeId); + if (at === -1) return false; // changing lanes is never a no-op + const currentBefore = lane.tasks[at - 1]?.id ?? null; + const currentAfter = lane.tasks[at + 1]?.id ?? null; + return intent.beforeId === currentBefore && intent.afterId === currentAfter; +} diff --git a/web/src/pages/TasksPage.tsx b/web/src/pages/TasksPage.tsx index b564e841..04f349f5 100644 --- a/web/src/pages/TasksPage.tsx +++ b/web/src/pages/TasksPage.tsx @@ -1,11 +1,14 @@ import { useEffect, useRef, useState, useCallback } from 'react'; -import { ChevronLeft, ChevronRight, Plus, Search, SlidersHorizontal, X } from 'lucide-react'; -import { useTaskStore, TASKS_PAGE_SIZE, type TaskSort } from '../stores/taskStore'; +import { useNavigate, useLocation } from 'react-router-dom'; +import { ChevronLeft, ChevronRight, Columns3, List, Plus, Search, SlidersHorizontal, X } from 'lucide-react'; +import { useTaskStore, TASKS_PAGE_SIZE, type TaskSort, type TaskViewMode } from '../stores/taskStore'; import { useTaskStatusStore } from '../stores/taskStatusStore'; import { TaskFilters } from '../components/Tasks/TaskFilters'; import { TaskCard } from '../components/Tasks/TaskCard'; import { TaskCreateDialog } from '../components/Tasks/TaskCreateDialog'; import { TaskStatusManager } from '../components/Tasks/TaskStatusManager'; +import { TaskBoard } from '../components/Tasks/Board/TaskBoard'; +import { BoardFilterBar } from '../components/Tasks/Board/BoardFilterBar'; const SORT_OPTIONS: { value: TaskSort; label: string }[] = [ { value: 'deadline', label: 'Deadline' }, @@ -13,20 +16,36 @@ const SORT_OPTIONS: { value: TaskSort; label: string }[] = [ { value: 'created_at', label: 'Created' }, ]; +const VIEW_OPTIONS: { value: TaskViewMode; label: string; Icon: typeof List }[] = [ + { value: 'board', label: 'Board', Icon: Columns3 }, + { value: 'list', label: 'List', Icon: List }, +]; + export function TasksPage() { const { tasks, filter, searchQuery, sort, page, total, loading, showCreateDialog, - loadTasks, setFilter, setSearch, setSort, setPage, + viewMode, loadTasks, loadBoard, loadTags, setViewMode, + setFilter, setSearch, setSort, setPage, updateStatus, createTask, setShowCreateDialog, } = useTaskStore(); const loadStatuses = useTaskStatusStore((s) => s.load); + const navigate = useNavigate(); + const location = useLocation(); const [localQuery, setLocalQuery] = useState(searchQuery); const [showStatusManager, setShowStatusManager] = useState(false); const debounceRef = useRef>(undefined); - useEffect(() => { loadTasks(); loadStatuses(); }, []); + const isBoard = viewMode === 'board'; + + useEffect(() => { + loadStatuses(); + loadTags(); + if (isBoard) loadBoard(); + else loadTasks(); + // Mount-only: view switches load through setViewMode. + }, []); const isSearching = searchQuery.trim().length > 0; const pageStart = total === 0 ? 0 : (page - 1) * TASKS_PAGE_SIZE + 1; @@ -50,14 +69,40 @@ export function TasksPage() { // Cleanup debounce on unmount useEffect(() => () => clearTimeout(debounceRef.current), []); + // Open a task over the board: /tasks/:id renders as a modal when it's + // reached from here, and as the full page on a cold load or refresh. + const openTask = useCallback((task: { id: string }) => { + navigate(`/tasks/${task.id}`, { state: { background: location } }); + }, [navigate, location]); + return ( -
-
-
-

Tasks

- +
+
+
+

Tasks

-
+
+ {VIEW_OPTIONS.map(({ value, label, Icon }) => ( + + ))} +
+ + {/* Status pills are the list's filter; on the board every status + is already a lane, so they'd only hide columns. */} + {!isBoard && } + +
-
- {!isSearching && ( +
+ {!isSearching && !isBoard && (