[4/7] Add a Kanban board view for tasks - #275
Conversation
|
Fixed a bug found in live testing: the search box did nothing on the board. The input was rendered in board view but wired only to the list — Searching is now server-side per lane, via a Two judgement calls worth flagging:
An empty board under an active search now says so once, instead of repeating "No tasks" in every column as if the board were empty. Added: 7 route tests for board search, 4 store tests (one pinning the exact regression — that
|
There was a problem hiding this comment.
Pull request overview
Adds a Kanban-style Board view for /tasks, backed by the new board API (lanes + server-authoritative ordering) and kept in sync via task_updated WebSocket broadcasts, while preserving the existing list view as an alternate mode.
Changes:
- Introduces board-mode state + reducers in the task store (lanes, optimistic moves with rollback, tags/search integration, persisted view mode).
- Adds a new drag-and-drop board UI (columns/cards, collapse state, tag filter bar, overlay) with unit tests for drop-intent geometry and store behavior.
- Extends the API client/types and backend board route to support board fetch, move intents, and server-side board search.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| web/src/stores/taskStore.ts | Adds board/list view state, board loading, optimistic move logic, tag/search handling, and task-updated event application. |
| web/src/stores/taskStore.test.ts | Adds reducer-level tests for optimistic moves, rollback/resync, event handling, and search refresh behavior. |
| web/src/stores/chatStore.ts | Hooks task_updated WS messages to the task store so the board stays live. |
| web/src/pages/TasksPage.tsx | Adds board/list toggle UI, mounts board/tag loading, and renders TaskBoard + board filters. |
| web/src/components/Tasks/Board/TaskBoard.tsx | Implements the DnD context, overlay, collapse state, and board-level empty/error/search states. |
| web/src/components/Tasks/Board/dropIntent.ts | Pure functions translating drag targets into {beforeId, afterId} move intents (+ no-op detection). |
| web/src/components/Tasks/Board/dropIntent.test.tsx | Unit tests covering drop geometry (including downward-drag anchor exclusion) and no-op detection. |
| web/src/components/Tasks/Board/BoardFilterBar.tsx | Adds tag facet filter UI for the board. |
| web/src/components/Tasks/Board/BoardColumn.tsx | Adds per-lane rendering with lane droppable background + pagination “+N more” messaging. |
| web/src/components/Tasks/Board/BoardCard.tsx | Adds draggable card UI optimized for board constraints (no inline status select). |
| web/src/api/websocket.ts | Extends WS message union to include global task_updated events. |
| web/src/api/client.ts | Adds typed Task, board endpoints, move endpoint, and updated task create/update response typing. |
| web/package.json | Adds @dnd-kit/* dependencies needed for the board drag-and-drop. |
| web/package-lock.json | Locks new @dnd-kit/* transitive dependencies. |
| tests/test_task_board_api.py | Adds HTTP-level tests for board search behavior and totals under search/tag filters. |
| nerve/gateway/routes/tasks.py | Extends /api/tasks/board to support q search and adjusts totals/count paths accordingly. |
Files not reviewed (1)
- web/package-lock.json: Generated file
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
/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 <select>: at 280px there's no room, 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. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The search input was rendered in board view but wired only to the list: setSearch called loadTasks(), which fills tasks[] — the list's state. The board renders from lanes[], so typing filtered an array nothing on screen was reading, and the board sat there unchanged. Searching now happens server-side, per lane, via a q parameter on /api/tasks/board. It has to be server-side rather than a filter over the loaded cards: a lane is paginated, so filtering what the client holds would silently miss matches deeper in the lane and report "no results" for tasks that exist. Hits keep their lane order rather than FTS relevance order. The board is spatial — reordering cards under a search would move them away from where the person looking already knows they are. Lane totals become the match count, so a filtered lane doesn't offer "+N more" for tasks the search excluded. An empty board under an active search now says so once, instead of repeating "No tasks" in every column as if the board itself were empty. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four review findings on the board. handleTaskEvent inserted any task it could not already find in a lane. That is right for a genuinely new card and wrong for the two cases that look identical from the client: a task sitting below the lane's loaded page, and one the active tag filter or search excludes. The first double-counts — once in the page, once in the total behind "+N more" — and the second puts a card on screen that contradicts the filter that is set. Only the server can tell these apart, so the insert is now trusted just for a whole, unfiltered lane and defers to a quiet reload otherwise. loadTasks applied the board's tag filter to the list. Nothing in list view sets that filter or shows it, so it silently hid rows with no chip to explain them and no control to clear them — and only while the search box was empty, since /tasks/search takes no tag, so typing changed the result set for no visible reason. It is board state; the list no longer reads it. The filter bar pinned the active tag with a non-null assertion, assuming a tag in use is always a tag on offer. It is not: the facets exclude done, so finishing or retagging the last open task holding a tag drops it from the list while the filter is still set. That pushed undefined into the render and threw — and with no error boundary anywhere in the app, that is a blank page. It now synthesises a zero-count chip, which keeps Clear reachable. The drag announcements were built from raw ids, so a screen reader heard "Picked up task 2026-08-05-fix-the-encoder", every target was called a lane even when it was a card, and a real lane came out as "lane:pending". They now read from the typed payload each draggable already carries. Pulled out as a pure function for the same reason dropIntent is one: it is the only account of the drag a screen-reader user gets, and it is invisible to everyone else, so it needs tests rather than a look. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ae09cee to
0b7ad5b
Compare
/taskswas a single column capped atmax-w-3xland centred — on a 2560px 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 change status, live updates as tasks change anywhere.Board is the default at ≥1280px, List below it, and the choice is remembered. List view is untouched — same component, same behaviour. A linear, searchable, paginated view is still the better tool once there are more tasks than fit on a screen; this is a second lens, not a replacement.
What it looks like:
Ordering is server-authoritative
A drop resolves to "put this between A and B" and the server computes the rank (#272). A board a few seconds stale therefore can't overwrite someone else's ordering with numbers derived from a lane it no longer matches.
dropIntent.tsholds 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; skip that and it anchors against its own current position and lands one slot short of where you dropped it. Every direction is pinned indropIntent.test.tsx.Moves are optimistic, with a real rollback
The card moves immediately. On failure the exact prior order is restored from a snapshot — totals included, since those drive the lane headers and the "+N more" affordance — then refetched, because a rejected move means the board was already out of date and the snapshot is only a stopgap.
Live updates
task_updatedmoves cards when the agent changes a task in an unrelated session, or when a second tab does. Handling is idempotent, since the event is also echoed back to the client that caused it. On this instance that's most of the appeal: the agent works tasks constantly.Two smaller decisions
<select>. At ~280px there's no room, and a card whose entire surface is a drag handle shouldn't also contain a control that swallows pointer events. Changing status here is the drag.Testing
npm test— 46 passed (27 new: 14 drop-geometry, 13 store)npx tsc -b— cleannpm run build— cleannpx eslint .— 150 problems vs 152 on the base; net −2 (typed the new API responses with the newTaskinterface rather than matching the file's existingany)The store specs caught a real bug while being written:
loadBoardclearedboardErrorsynchronously, and since a failed move fires a quiet resync immediately afterward, the "move failed" banner was wiped before it could ever render. Error ownership is now explicit — only an explicit load clears it.Not verified yet: the actual drag, in a browser. The drop geometry is tested as pure functions and the store reducers are tested directly, but dnd-kit's sensor wiring, the auto-scroll, the DragOverlay, and keyboard dragging are not — and they can't be until #272's API is deployed, since
GET /api/tasks/board404s on the current build. That needs anerve restarton this instance, which restarts the agent, so I'm asking before doing it. Live pass to follow; I'll report back here.Follow-ups, deliberately not here
TaskDetailPage, which works fine. The modal is the next PR./moveroutes through the note-less path, so every drag is currently an untracked mutation. A board is also what makes people ask "how long has this been in progress?" — unanswerable today.🤖 Generated with Claude Code