feat(board): multi-select cards with cmd/shift-click + stack-drag #420

Merged
code-lead merged 4 commits from dev/411 into main 2026-04-27 07:35:52 +00:00
Collaborator

Multi-select stack-drag for the Kanban board: cmd/ctrl-click toggles per-card selection, shift-click extends a contiguous range within the same column, dragging any selected card carries the full stack.

Test plan

  • board.test.tsx — cmd-click toggles data-selected; deselects on second click
  • board.test.tsx — shift-click extends range 1..3; cross-column shift resets to card 4 only
  • board.test.tsx — drag of 3 selected cards passes all 3 to onAssign in one call
  • board.test.tsx — batch rollback: one rejected assignCard restores only the failed card, successful cards stay in target
  • board.test.tsx — Esc clears selection; cmd-a after mouseenter selects all in dev, not reviewer
  • All 447 web tests + 1999 server tests green

Closes #411

Multi-select stack-drag for the Kanban board: cmd/ctrl-click toggles per-card selection, shift-click extends a contiguous range within the same column, dragging any selected card carries the full stack. ## Test plan - `board.test.tsx` — cmd-click toggles `data-selected`; deselects on second click - `board.test.tsx` — shift-click extends range 1..3; cross-column shift resets to card 4 only - `board.test.tsx` — drag of 3 selected cards passes all 3 to `onAssign` in one call - `board.test.tsx` — batch rollback: one rejected `assignCard` restores only the failed card, successful cards stay in target - `board.test.tsx` — Esc clears selection; `cmd-a` after mouseenter selects all in dev, not reviewer - All 447 web tests + 1999 server tests green Closes #411
feat(board): multi-select cards with cmd/shift-click + stack-drag
All checks were successful
qa / qa (pull_request) Successful in 5m33s
qa / dockerfile (pull_request) Successful in 8s
3446318bac
- cmd/ctrl-click toggles per-card selection (column-scoped; cross-column
  click clears the previous column's selection).
- shift-click extends a contiguous range from the most recent anchor
  within the same column; cross-column shift-click resets the anchor.
- Selected cards show a 2 px border-accent ring and a ✓ badge.
- Dragging any selected card carries the full selection as a stack;
  a stacked-card drag image with a count badge is shown via setDragImage.
- Dragging a non-selected card while a selection exists drags only that
  card (no implicit selection).
- Drop fires onAssign(cards[], targetType) for all carried cards;
  the route fans out via Promise.allSettled with per-card optimistic
  updates and per-card rollback on failure, surfacing a toast:
  "Failed to assign N of M cards: <reason>".
- Esc clears selection; cmd-a selects all visible cards in the focused
  column (suppressed when focus is in a text input).
- 5 new board.test.tsx cases: cmd-click toggle, shift-click range,
  3-card stack-drag, batch rollback contract, Esc/cmd-a keyboard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev requested review from reviewer 2026-04-27 00:39:12 +00:00
reviewer requested changes 2026-04-27 00:42:21 +00:00
Dismissed
reviewer left a comment

CI green. Three issues to fix before merge.

  • behavior board.tsx:159,162setAnchorKey is called inside the setSelectedKeys functional updater. React requires updater functions to be pure (no side effects); in StrictMode the updater runs twice, so the anchor double-toggles on every cmd-click. Move the setAnchorKey call outside the updater:

    // outside setSelectedKeys
    let nextAnchor = anchorKey;
    setSelectedKeys((prev) => {
      const next = new Set(prev);
      if (focusedColumnType && colType !== focusedColumnType) next.clear();
      if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; }
      else              { next.add(key);    nextAnchor = key; }
      return next;
    });
    setAnchorKey(nextAnchor);
    
  • test-gap board.test.tsx — The AC says "drag of a non-selected card while selection exists drags only the dragged card (does not implicitly select)." No test covers this path. Add a test: cmd-click two cards to build a selection, then drag a third (unselected) card — onAssign should receive only that third card.

  • test-gap board.test.tsx — The batch-rollback test uses runBatchAssign, which strips the showToast call from planner.board.tsx. The AC requires Failed to assign N of M cards: <reason>. Either mock showToast in the test and assert it, or pull the toast assertion into an integration test that mounts BoardRoute with a mocked assignCard.

(Note for awareness, not a blocker: the "Drop on Unassigned" AC item is currently gated behind B1; the code comment acknowledges it as a follow-up. Fine to land as-is once the three items above are addressed.)

CI green. Three issues to fix before merge. - **behavior** `board.tsx:159,162` — `setAnchorKey` is called inside the `setSelectedKeys` functional updater. React requires updater functions to be pure (no side effects); in StrictMode the updater runs twice, so the anchor double-toggles on every cmd-click. Move the `setAnchorKey` call outside the updater: ```ts // outside setSelectedKeys let nextAnchor = anchorKey; setSelectedKeys((prev) => { const next = new Set(prev); if (focusedColumnType && colType !== focusedColumnType) next.clear(); if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; } else { next.add(key); nextAnchor = key; } return next; }); setAnchorKey(nextAnchor); ``` - **test-gap** `board.test.tsx` — The AC says "drag of a non-selected card while selection exists drags only the dragged card (does not implicitly select)." No test covers this path. Add a test: cmd-click two cards to build a selection, then drag a third (unselected) card — `onAssign` should receive only that third card. - **test-gap** `board.test.tsx` — The batch-rollback test uses `runBatchAssign`, which strips the `showToast` call from `planner.board.tsx`. The AC requires `Failed to assign N of M cards: <reason>`. Either mock `showToast` in the test and assert it, or pull the toast assertion into an integration test that mounts `BoardRoute` with a mocked `assignCard`. (Note for awareness, not a blocker: the "Drop on Unassigned" AC item is currently gated behind B1; the code comment acknowledges it as a follow-up. Fine to land as-is once the three items above are addressed.)
dev force-pushed dev/411 from 3446318bac
All checks were successful
qa / qa (pull_request) Successful in 5m33s
qa / dockerfile (pull_request) Successful in 8s
to a575a01ccf
All checks were successful
qa / qa (pull_request) Successful in 6m9s
qa / dockerfile (pull_request) Successful in 9s
2026-04-27 01:08:23 +00:00
Compare
dev requested review from reviewer 2026-04-27 01:23:40 +00:00
reviewer requested changes 2026-04-27 01:25:51 +00:00
Dismissed
reviewer left a comment

CI green. None of the three round-1 findings were addressed — re-raising them verbatim.

  • behavior board.tsx (cmd-click handler, setSelectedKeys call) — setAnchorKey is still invoked inside the functional updater passed to setSelectedKeys. React requires updater functions to be pure; StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both setAnchorKey call-sites outside the updater:

    let nextAnchor = anchorKey;
    setSelectedKeys((prev) => {
      const next = new Set(prev);
      if (focusedColumnType && colType !== focusedColumnType) next.clear();
      if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; }
      else               { next.add(key);    nextAnchor = key; }
      return next;
    });
    setAnchorKey(nextAnchor);
    if (colType) setFocusedColumnType(colType);
    
  • test-gap board.test.tsx — Still no test for "drag of non-selected card while selection exists drags only that card." The DraggableCard correctly uses the single-card branch when isSelected is false, but the AC mandates a test. Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — onAssign should receive [card3] only, not the full stack.

  • test-gap board.test.tsx — The batch-rollback describe block uses the runBatchAssign helper, which deliberately omits the showToast call. planner.board.tsx does call showToast(\Failed to assign ${failedCards.length} of ${cards.length} cards: ${reason}`, "error"), but nothing asserts it. Either mock showToastin the batch-rollback test and assert the call, or add an integration test that mountsBoardRoutewith a mockedassignCard`.

CI green. None of the three round-1 findings were addressed — re-raising them verbatim. - **behavior** `board.tsx` (cmd-click handler, `setSelectedKeys` call) — `setAnchorKey` is still invoked inside the functional updater passed to `setSelectedKeys`. React requires updater functions to be pure; StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both `setAnchorKey` call-sites outside the updater: ```ts let nextAnchor = anchorKey; setSelectedKeys((prev) => { const next = new Set(prev); if (focusedColumnType && colType !== focusedColumnType) next.clear(); if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; } else { next.add(key); nextAnchor = key; } return next; }); setAnchorKey(nextAnchor); if (colType) setFocusedColumnType(colType); ``` - **test-gap** `board.test.tsx` — Still no test for "drag of non-selected card while selection exists drags only that card." The `DraggableCard` correctly uses the single-card branch when `isSelected` is false, but the AC mandates a test. Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — `onAssign` should receive `[card3]` only, not the full stack. - **test-gap** `board.test.tsx` — The batch-rollback describe block uses the `runBatchAssign` helper, which deliberately omits the `showToast` call. `planner.board.tsx` does call `showToast(\`Failed to assign ${failedCards.length} of ${cards.length} cards: ${reason}\`, "error")`, but nothing asserts it. Either mock `showToast` in the batch-rollback test and assert the call, or add an integration test that mounts `BoardRoute` with a mocked `assignCard`.
dev force-pushed dev/411 from a575a01ccf
All checks were successful
qa / qa (pull_request) Successful in 6m9s
qa / dockerfile (pull_request) Successful in 9s
to 08d660ae32
All checks were successful
qa / qa (pull_request) Successful in 5m59s
qa / dockerfile (pull_request) Successful in 8s
2026-04-27 01:26:43 +00:00
Compare
dev requested review from reviewer 2026-04-27 01:54:50 +00:00
reviewer requested changes 2026-04-27 01:58:34 +00:00
Dismissed
reviewer left a comment

CI green. All three findings from rounds 1 and 2 remain unaddressed — re-raising verbatim for the final time. If these aren't resolved next push, the loop hits MAX_ROUNDS and escalates to human review.

  • behavior board.tsx:174–186setAnchorKey is still called at lines 180 and 183 inside the functional updater passed to setSelectedKeys. React requires updater functions to be pure (no side effects). StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both setAnchorKey calls outside the updater:

    let nextAnchor = anchorKey;
    setSelectedKeys((prev) => {
      const next = new Set(prev);
      if (focusedColumnType && colType !== focusedColumnType) next.clear();
      if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; }
      else               { next.add(key);    nextAnchor = key; }
      return next;
    });
    setAnchorKey(nextAnchor);
    if (colType) setFocusedColumnType(colType);
    
  • test-gap board.test.tsx — Still no test for "drag of a non-selected card while a selection exists drags only that card." The AC requires it. Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — onAssign must receive [card3] only, not the full stack.

  • test-gap board.test.tsx:846–954 — The runBatchAssign helper omits the showToast(...) call that planner.board.tsx:210 makes on partial failure (Failed to assign N of M cards: <reason>). Nothing asserts the toast. Either mock showToast in the batch-rollback test and assert the call, or promote the test to mount BoardRoute with a mocked assignCard.

CI green. All three findings from rounds 1 and 2 remain unaddressed — re-raising verbatim for the final time. If these aren't resolved next push, the loop hits MAX_ROUNDS and escalates to human review. - **behavior** `board.tsx:174–186` — `setAnchorKey` is still called at lines 180 and 183 **inside** the functional updater passed to `setSelectedKeys`. React requires updater functions to be pure (no side effects). StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both `setAnchorKey` calls outside the updater: ```ts let nextAnchor = anchorKey; setSelectedKeys((prev) => { const next = new Set(prev); if (focusedColumnType && colType !== focusedColumnType) next.clear(); if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; } else { next.add(key); nextAnchor = key; } return next; }); setAnchorKey(nextAnchor); if (colType) setFocusedColumnType(colType); ``` - **test-gap** `board.test.tsx` — Still no test for "drag of a non-selected card while a selection exists drags only that card." The AC requires it. Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — `onAssign` must receive `[card3]` only, not the full stack. - **test-gap** `board.test.tsx:846–954` — The `runBatchAssign` helper omits the `showToast(...)` call that `planner.board.tsx:210` makes on partial failure (`Failed to assign N of M cards: <reason>`). Nothing asserts the toast. Either mock `showToast` in the batch-rollback test and assert the call, or promote the test to mount `BoardRoute` with a mocked `assignCard`.
dev force-pushed dev/411 from 08d660ae32
All checks were successful
qa / qa (pull_request) Successful in 5m59s
qa / dockerfile (pull_request) Successful in 8s
to 53afaff0a9
All checks were successful
qa / qa (pull_request) Successful in 6m9s
qa / dockerfile (pull_request) Successful in 9s
2026-04-27 02:14:40 +00:00
Compare
dev requested review from reviewer 2026-04-27 02:39:52 +00:00
reviewer requested changes 2026-04-27 02:42:08 +00:00
Dismissed
reviewer left a comment

CI green. All three findings from rounds 1–3 remain unaddressed — re-raising verbatim for the last time before human escalation.

  • behavior board.tsx (cmd-click handler) — setAnchorKey is still called at both branches inside the functional updater passed to setSelectedKeys. React requires updater functions to be pure (no side effects); StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both setAnchorKey call-sites outside the updater:

    let nextAnchor = anchorKey;
    setSelectedKeys((prev) => {
      const next = new Set(prev);
      if (focusedColumnType && colType !== focusedColumnType) next.clear();
      if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; }
      else               { next.add(key);    nextAnchor = key; }
      return next;
    });
    setAnchorKey(nextAnchor);
    if (colType) setFocusedColumnType(colType);
    
  • test-gap board.test.tsx — Still no test for "drag of a non-selected card while a selection exists drags only that card." Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — onAssign must receive [card3] only, not the full stack.

  • test-gap board.test.tsx — The runBatchAssign helper still omits the showToast(...) call that planner.board.tsx makes on partial failure. The batch-rollback test only asserts card positions; nothing verifies "Failed to assign N of M cards: <reason>" is surfaced. Either mock showToast and assert it, or mount BoardRoute with a mocked assignCard.

CI green. All three findings from rounds 1–3 remain unaddressed — re-raising verbatim for the last time before human escalation. - **behavior** `board.tsx` (cmd-click handler) — `setAnchorKey` is still called at both branches **inside** the functional updater passed to `setSelectedKeys`. React requires updater functions to be pure (no side effects); StrictMode double-invokes them, so the anchor double-toggles on every cmd-click. Move both `setAnchorKey` call-sites outside the updater: ```ts let nextAnchor = anchorKey; setSelectedKeys((prev) => { const next = new Set(prev); if (focusedColumnType && colType !== focusedColumnType) next.clear(); if (next.has(key)) { next.delete(key); if (anchorKey === key) nextAnchor = null; } else { next.add(key); nextAnchor = key; } return next; }); setAnchorKey(nextAnchor); if (colType) setFocusedColumnType(colType); ``` - **test-gap** `board.test.tsx` — Still no test for "drag of a non-selected card while a selection exists drags only that card." Add: cmd-click cards 1 and 2 to build a selection, then drag card 3 (unselected) to reviewer — `onAssign` must receive `[card3]` only, not the full stack. - **test-gap** `board.test.tsx` — The `runBatchAssign` helper still omits the `showToast(...)` call that `planner.board.tsx` makes on partial failure. The batch-rollback test only asserts card positions; nothing verifies `"Failed to assign N of M cards: <reason>"` is surfaced. Either mock `showToast` and assert it, or mount `BoardRoute` with a mocked `assignCard`.
fix(board): pure updater for setSelectedKeys + 2 missing tests
All checks were successful
qa / qa (pull_request) Successful in 5m51s
qa / dockerfile (pull_request) Successful in 8s
e53b45fe65
- Move setAnchorKey out of the setSelectedKeys functional updater so the
  updater is pure (React StrictMode double-invokes updaters, which was
  double-toggling the anchor on every cmd-click).
- Add test: dragging a non-selected card while a selection exists drags
  only that card, not the full selection stack.
- Add showToast mock + assertion to the batch-rollback test so the
  "Failed to assign N of M cards: <reason>" message is verified.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
dev requested review from reviewer 2026-04-27 03:37:11 +00:00
reviewer approved these changes 2026-04-27 03:38:21 +00:00
Dismissed
reviewer left a comment

All three round 1–4 findings addressed. Pure updater pattern is correct and well-commented; the non-selected-card-drag test and the toast spy assertion are both present and complete. CI green.

All three round 1–4 findings addressed. Pure updater pattern is correct and well-commented; the non-selected-card-drag test and the toast spy assertion are both present and complete. CI green.
reviewer approved these changes 2026-04-27 03:38:40 +00:00
reviewer left a comment

CI green. All three round 1–4 findings addressed: pure updater (nextAnchor pattern, comment explains why), non-selected card drag test, and toast spy assertion in the batch-rollback test.

CI green. All three round 1–4 findings addressed: pure updater (`nextAnchor` pattern, comment explains why), non-selected card drag test, and toast spy assertion in the batch-rollback test.
dev force-pushed dev/411 from e53b45fe65
All checks were successful
qa / qa (pull_request) Successful in 5m51s
qa / dockerfile (pull_request) Successful in 8s
to 525c40dc6b
Some checks failed
qa / qa (pull_request) Failing after 3m11s
qa / dockerfile (pull_request) Successful in 8s
2026-04-27 03:48:40 +00:00
Compare
fix(ci): widen handleDrop columnType to string | null to match BoardColumnView prop
All checks were successful
qa / qa (pull_request) Successful in 5m56s
qa / dockerfile (pull_request) Successful in 9s
9021680511
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
charles force-pushed dev/411 from 9021680511
All checks were successful
qa / qa (pull_request) Successful in 5m56s
qa / dockerfile (pull_request) Successful in 9s
to 7c4d66c2be
Some checks are pending
qa / qa (pull_request) Waiting to run
qa / dockerfile (pull_request) Waiting to run
2026-04-27 05:39:44 +00:00
Compare
charles force-pushed dev/411 from 7c4d66c2be
Some checks are pending
qa / qa (pull_request) Waiting to run
qa / dockerfile (pull_request) Waiting to run
to 8e8225f1e1
Some checks failed
qa / qa (pull_request) Failing after 5m34s
qa / dockerfile (pull_request) Successful in 8s
2026-04-27 05:40:45 +00:00
Compare
fix(board): compute nextAnchor from current selectedKeys before setSelectedKeys call
All checks were successful
qa / qa (pull_request) Successful in 5m51s
qa / dockerfile (pull_request) Successful in 8s
efac8d3b62
React functional updaters run during reconciliation — after all setState
calls in the event handler complete — so mutating nextAnchor inside the
setSelectedKeys updater and reading it immediately after always saw the
pre-update value (null on first cmd-click). Fix by computing the new
anchor synchronously from the current selectedKeys before the updater,
and add selectedKeys to the useCallback dependency array.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
charles force-pushed dev/411 from efac8d3b62
All checks were successful
qa / qa (pull_request) Successful in 5m51s
qa / dockerfile (pull_request) Successful in 8s
to 5075c0df58
All checks were successful
qa / qa (pull_request) Successful in 6m54s
qa / dockerfile (pull_request) Successful in 12s
2026-04-27 07:28:13 +00:00
Compare
code-lead deleted branch dev/411 2026-04-27 07:35:53 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!420
No description provided.