Skip to content

[4/7] Add a Kanban board view for tasks - #275

Open
alex-clickhouse wants to merge 3 commits into
alex-clickhouse/web-test-harnessfrom
alex-clickhouse/task-board-ui
Open

[4/7] Add a Kanban board view for tasks#275
alex-clickhouse wants to merge 3 commits into
alex-clickhouse/web-test-harnessfrom
alex-clickhouse/task-board-ui

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Stacked on #274#273#272main. Needs #272's API at runtime.

/tasks was a single column capped at max-w-3xl and 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:

image image

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.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; skip that and it anchors against its own current position and lands one slot short of where you dropped it. Every direction is pinned in dropIntent.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_updated moves 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

  • 4px drag activation distance — so a plain click still opens the task instead of starting a drag.
  • The board card drops the list card's inline status <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 — clean
  • npm run build — clean
  • npx eslint . — 150 problems vs 152 on the base; net −2 (typed the new API responses with the new Task interface rather than matching the file's existing any)

The store specs caught a real bug while being written: loadBoard cleared boardError synchronously, 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/board 404s on the current build. That needs a nerve restart on 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

  • Task detail modal + background-location routing — clicking a card currently navigates to the existing full TaskDetailPage, which works fine. The modal is the next PR.
  • No status-transition history exists anywhere in Nerve (filed separately). Dragging is a cheap, frequent status change and /move routes 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

@alex-clickhouse

Copy link
Copy Markdown
Collaborator Author

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 — setSearch called loadTasks(), which fills tasks[]. The board renders from lanes[], so typing filtered an array nothing on screen was reading.

Searching is now server-side per lane, via a q param on /api/tasks/board. Deliberately not a client-side filter over the loaded cards: lanes are paginated, so filtering what the client holds would silently miss matches deeper in a lane and report "no results" for tasks that exist. test_board_search_finds_a_task_beyond_the_lane_page pins exactly that.

Two judgement calls worth flagging:

  • Hits keep lane order, not FTS relevance order. The board is spatial; reordering cards under a search moves 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 were empty.

Added: 7 route tests for board search, 4 store tests (one pinning the exact regression — that setSearch refreshes the board and not the list). pytest 3102 passed, vitest 50 passed, tsc + build clean.

alex-clickhouse/task-detail-modal and alex-clickhouse/task-events were rebased onto this; the one conflict was loadBoard needing both the search query and the status_since read, resolved to keep both.

@alex-clickhouse
alex-clickhouse requested a review from Copilot August 5, 2026 10:38
@alex-clickhouse alex-clickhouse changed the title Add a Kanban board view for tasks [4/6] Add a Kanban board view for tasks Aug 5, 2026

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread web/src/stores/taskStore.ts
Comment thread web/src/stores/taskStore.ts Outdated
Comment thread web/src/components/Tasks/Board/BoardFilterBar.tsx Outdated
Comment thread web/src/components/Tasks/Board/TaskBoard.tsx Outdated
@alex-clickhouse alex-clickhouse changed the title [4/6] Add a Kanban board view for tasks [4/7] Add a Kanban board view for tasks Aug 5, 2026
alex-clickhouse and others added 3 commits August 5, 2026 11:51
/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>
@alex-clickhouse
alex-clickhouse force-pushed the alex-clickhouse/task-board-ui branch from ae09cee to 0b7ad5b Compare August 5, 2026 12:07
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review August 5, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants