Skip to content

Comments

refactor: decompose KanbanBoard God Component into focused hooks#147

Merged
ryota-murakami merged 6 commits intomainfrom
feat/bulk-issues-20260220
Feb 20, 2026
Merged

refactor: decompose KanbanBoard God Component into focused hooks#147
ryota-murakami merged 6 commits intomainfrom
feat/bulk-issues-20260220

Conversation

@ryota-murakami
Copy link
Contributor

@ryota-murakami ryota-murakami commented Feb 20, 2026

Summary

KanbanBoard was 825 lines with 3+ mixed concerns (DnD, undo, comments, search API). This PR decomposes it into focused, testable hooks following established patterns.

Result: KanbanBoard reduced from ~825 → ~295 lines. Zero behavior changes.

Closes #141, Closes #142, Closes #143, Closes #144, Closes #145

Architecture

KanbanBoard.tsx (295 lines, rendering only)
├── useKanbanDnD()        — sensors, handlers, grid layout
├── useKanbanUndo()       — history + Z-key shortcut
├── useCommentState()     — comment CRUD + optimistic updates
└── useRepositorySearch() — sealed API (no raw setters exposed)

Key Decisions

Decision Rationale
Merged #145 into #141 Sub-handlers are private to handleDragEnd, no meaningful intermediate state
History bridging pattern useKanbanUndo provides pushCardHistory/pushColumnHistory callbacks passed into useKanbanDnD
forgivingCollisionDetection exported from hook file Pure function, imported by KanbanBoard for <DndContext>

Test plan

  • pnpm lint — clean
  • pnpm typecheck — clean
  • pnpm test — 1282 passed
  • pnpm build — success
  • pnpm e2e:parallel — 11/12 shards passed (shard 1 failure is pre-existing infrastructure issue, confirmed identical on main)

Summary by CodeRabbit

  • New Features

    • Undo for board actions via keyboard shortcut.
    • Improved inline comment editing with faster, optimistic updates and clearer feedback.
    • Smoother repository add flow with clearer selection/search reset and error handling.
  • Refactor

    • Major internal rewrite of board drag-and-drop and state management into modular hooks for more reliable interactions and easier maintenance.

…antic actions (#143)

- Replace setSearchQuery with updateSearch
- Replace setSelectedRepos([]) with clearSelection
- Replace setIsAdding(true/false) with startAdding/finishAdding
- Replace setAddError with setAddingError/clearError
- All actions wrapped in useCallback for referential stability
- startAdding() combines setIsAdding(true) + clearError() atomically
- Extract ~195 lines of DnD logic into src/hooks/board/useKanbanDnD.ts
- Move forgivingCollisionDetection, sensors, drag handlers, grid calculations
- Column sub-handlers (handleNewRowDrop, handleColumnInsertDrop, handleColumnSwap) included
- handleDragEnd split into column/card branches per #145
- History state stays in KanbanBoard via pushCardHistory/pushColumnHistory callbacks
- All handlers wrapped in useCallback with correct dependency arrays
- Extract history/columnHistory state, handleUndo, Z-key useEffect
- Hook is self-contained: keyboard shortcut registered internally
- Returns pushCardHistory/pushColumnHistory for useKanbanDnD consumption
- KanbanBoard no longer imports setStatusLists/setRepoCards directly
- Removes ~73 lines from KanbanBoard
- Extract comments state + 3 CRUD handlers into useCommentState hook
- Preserve optimistic updates, Sentry error tracking, and toast feedback
- KanbanBoard now imports zero direct server actions
- Removed unused imports: useState, useCallback, Sentry, toast, CommentColor
- KanbanBoard reduced from 825 → 295 lines (completes decomposition)
@vercel
Copy link
Contributor

vercel bot commented Feb 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gitbox Ready Ready Preview, Comment Feb 20, 2026 1:51pm

Request Review

@coderabbitai
Copy link

coderabbitai bot commented Feb 20, 2026

📝 Walkthrough

Walkthrough

The PR extracts DnD, undo/history, and comment-management logic from KanbanBoard into three new hooks (useKanbanDnD, useKanbanUndo, useCommentState), updates exports, and seals useRepositorySearch by replacing raw setters with semantic action handlers; KanbanBoard is reduced by several hundred lines and wired to the new hooks.

Changes

Cohort / File(s) Summary
DnD Hook
src/hooks/board/useKanbanDnD.ts
New hook containing sensors, forgiving collision detection, drag state (activeId/activeDragType), grid/insertion-zone calculations, handleDragStart/handleDragEnd, column/card sub-handlers, Redux updates, DB persistence, and Sentry error/rollback logic. Exports types and forgivingCollisionDetection.
Undo Hook
src/hooks/board/useKanbanUndo.ts
New hook providing dual undo stacks (columns/cards), push callbacks, handleUndo with DB sync and Sentry/toast feedback, and keyboard shortcut registration.
Comment State Hook
src/hooks/board/useCommentState.ts
New hook managing per-card comments (optimistic updates) with handlers for change, color change, and delete; persists via server actions and reports errors to Sentry with toasts.
Hook Re-exports
src/hooks/board/index.ts
Added public exports for useKanbanDnD, forgivingCollisionDetection, useKanbanUndo, and useCommentState.
KanbanBoard Component
src/components/Board/KanbanBoard.tsx
Refactored to delegate DnD, undo, and comment concerns to the new hooks; removed large inline drag/undo/comment logic and re-wired handlers and imports.
Repository Search API
src/hooks/board/useRepositorySearch.ts
Replaced raw state setters with semantic actions (e.g., setSearchQueryupdateSearch, setSelectedReposclearSelection, setIsAdding/setAddErrorstartAdding/finishAdding/setAddingError/clearError); updated return type accordingly.
Combobox Consumer
src/components/Board/AddRepositoryCombobox.tsx
Updated component to use the new semantic actions from useRepositorySearch (e.g., updateSearch, clearSelection, startAdding/finishAdding, setAddingError) instead of raw setters.
package manifest
package.json
Minor manifest changes (lines changed +14/-12) — no public API signature changes across components.

Sequence Diagram(s)

sequenceDiagram
  participant User as "User (drag)"
  participant Board as "KanbanBoard"
  participant Hook as "useKanbanDnD"
  participant Redux as "Redux (dispatch)"
  participant API as "Server/API"
  participant Sentry as "Sentry / Toasts"

  Note over User,Hook: Card drag & drop flow
  User->>Board: drag end event
  Board->>Hook: handleDragEnd(active, over)
  Hook->>Redux: pushCardHistory(snapshot)
  Hook->>Redux: dispatch local reorder/move
  Hook->>API: persist change (batchUpdate / updateRepoCardPosition)
  API-->>Hook: success / failure
  alt success
    Hook->>Board: finish (clear active)
  else failure
    Hook->>Redux: rollback previous snapshot
    Hook->>Sentry: report error
    Hook->>Board: show toast error
  end
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Poem

✂️ The big board split and learned to share,
Hooks took the logic, tidy and fair.
DnD, Undo, Comments now each play their part —
Search sealed up neat, no raw setters to start. 🌿

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately captures the main change: decomposing KanbanBoard into focused hooks, which is the primary objective of this refactoring PR.
Linked Issues check ✅ Passed All five linked issues (#141-#145) are addressed: useKanbanDnD extracted with DnD logic, useKanbanUndo with undo/Z-key handling, useRepositorySearch sealed with semantic actions, useCommentState extracted with CRUD operations, and handleDragEnd split into sub-handlers within the hook.
Out of Scope Changes check ✅ Passed All changes remain within scope of the decomposition objectives. The hook extractions, API sealing of useRepositorySearch, and AddRepositoryCombobox refactoring directly address the five linked issues with no unrelated modifications.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/bulk-issues-20260220

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@morph-subagents
Copy link

🤖 Morph Preview Test

Looks like you hit your rate limits!

Please upgrade your limits here, or wait a few minutes and try again.

If you need help, reach out to support@morphllm.com.


Automated testing by Morph

@codecov-commenter
Copy link

codecov-commenter commented Feb 20, 2026

Codecov Report

❌ Patch coverage is 35.78947% with 183 lines in your changes missing coverage. Please review.
✅ Project coverage is 69.29%. Comparing base (4e3451a) to head (44f09ac).

Files with missing lines Patch % Lines
src/hooks/board/useKanbanDnD.ts 23.21% 129 Missing ⚠️
src/hooks/board/useCommentState.ts 34.21% 25 Missing ⚠️
src/hooks/board/useKanbanUndo.ts 68.62% 16 Missing ⚠️
src/components/Board/AddRepositoryCombobox.tsx 12.50% 7 Missing ⚠️
src/hooks/board/useRepositorySearch.ts 62.50% 6 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #147      +/-   ##
==========================================
+ Coverage   69.27%   69.29%   +0.01%     
==========================================
  Files         146      149       +3     
  Lines        4554     4602      +48     
  Branches     1216     1226      +10     
==========================================
+ Hits         3155     3189      +34     
- Misses       1377     1391      +14     
  Partials       22       22              

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

🧹 Nitpick comments (2)
src/hooks/board/useKanbanUndo.ts (1)

75-121: handleUndo recreated on every history push causes event listener churn.

handleUndo closes over history and columnHistory state, so it's recreated on every push. The useEffect on line 124 then tears down and re-registers the keydown listener each time. Using refs for the stacks (read in handleUndo, written in push callbacks) would give a stable handleUndo identity and avoid this re-registration cycle.

Also applies to: 124-145

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/board/useKanbanUndo.ts` around lines 75 - 121, The handleUndo
callback closes over history and columnHistory so its identity changes whenever
those arrays change; replace the collapsing dependency by keeping stable refs
for the stacks (e.g., historyRef and columnHistoryRef) that mirror state and are
written to inside the push/update logic, then have handleUndo read from
historyRef.current and columnHistoryRef.current so it can be memoized without
history/columnHistory in its dependency array; update the push callbacks that
call setHistory/setColumnHistory to also update the corresponding ref, and
ensure the useEffect that registers the keydown listener uses the stable
handleUndo (so it no longer tears down/re-registers on each stack push).
src/hooks/board/useKanbanDnD.ts (1)

222-224: Align updateStatusListPosition import with other board actions.

The function is already exported from @/lib/actions/board (line 254), but it's dynamically imported here while all other board actions (updateRepoCardPosition, batchUpdateRepoCardOrders, swapStatusListPositions, batchUpdateStatusListPositions) are statically imported at lines 42–46. Move it to the static imports for consistency.

Proposed fix

Add to the static imports at lines 42–46:

 import {
   updateRepoCardPosition,
   batchUpdateRepoCardOrders,
   swapStatusListPositions,
   batchUpdateStatusListPositions,
+  updateStatusListPosition,
 } from '@/lib/actions/board'

Then simplify the handler:

-      try {
-        const { updateStatusListPosition } = await import('@/lib/actions/board')
-        await updateStatusListPosition(activeStatus.id, targetRow, targetCol)
+      try {
+        await updateStatusListPosition(activeStatus.id, targetRow, targetCol)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/board/useKanbanDnD.ts` around lines 222 - 224, The dynamic import
of updateStatusListPosition inside the try block should be made consistent with
other board actions by moving updateStatusListPosition to the static imports
alongside updateRepoCardPosition, batchUpdateRepoCardOrders,
swapStatusListPositions, and batchUpdateStatusListPositions, and then remove the
inline await import and call updateStatusListPosition(activeStatus.id,
targetRow, targetCol) directly; update the import list (where those other
actions are imported) and simplify the try block to call
updateStatusListPosition by name.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/board/useCommentState.ts`:
- Around line 72-88: handleCommentChange currently applies optimistic state via
setComments then ignores failures from updateComment when result.success is
false; capture the previous comment for cardId before calling setComments, and
on failure revert the state using setComments(prev => ({...prev, [cardId]:
previous})) and surface an error to the user (e.g., trigger a
toast/notification) so they see the save failed; keep references to the
functions/variables updateComment, setComments, result.success and ensure the
rollback runs inside the failure branch and the user-visible notification is
invoked.
- Around line 97-117: The handlers handleCommentColorChange and
handleCommentDelete currently use try/catch but the underlying functions
updateCommentColor and deleteComment return ActionResult<void> and never throw,
so change both to follow the handleCommentChange pattern: call
updateCommentColor/deleteComment, inspect the returned result.success and handle
result.success === false by logging (avoid double-reporting to Sentry if server
already reports) and showing toast.error; only use Sentry.captureException here
if the result contains an error payload and server did not already log it.
Ensure you still optimistically update state (setComments) before the call and
revert or show an error when result.success is false.

In `@src/hooks/board/useKanbanDnD.ts`:
- Around line 436-457: Cross-column moves ignore the visual drop target and
instead compute newOrder by finding activeCard in the filtered array; update
this to respect over.id like the same-column path: in the else branch (where
activeCard, cards, targetStatusId are used and dispatch(setRepoCards(...)) is
called) compute targetColumnCards then determine newOrder using over.id — if
over.id corresponds to a card in targetColumnCards use findIndex(c => c.id ===
over.id) (and insert before/after as your same-column logic expects), and if
over.id is a column/zone treat it as append (newOrder =
targetColumnCards.length); then call updateRepoCardPosition(activeCard.id,
targetStatusId, newOrder) and keep the existing Sentry + rollback behavior.

In `@src/hooks/board/useKanbanUndo.ts`:
- Around line 136-140: The Z-key handler in useKanbanUndo currently triggers on
any 'z'/'Z' press and prevents default, causing conflicts with Ctrl/Cmd+Z
browser undo; update the key check in the keyboard listener (the block that
currently tests event.key === 'z' || event.key === 'Z') to only trigger when no
modifier keys are pressed (e.g. require !event.ctrlKey && !event.metaKey, and
optionally !event.altKey if desired) before calling handleUndo() and calling
event.preventDefault(); ensure you keep the existing uppercase/lowercase
handling and update the conditional in the same listener so bare-Z triggers
board undo but Ctrl/Cmd+Z does not.

---

Nitpick comments:
In `@src/hooks/board/useKanbanDnD.ts`:
- Around line 222-224: The dynamic import of updateStatusListPosition inside the
try block should be made consistent with other board actions by moving
updateStatusListPosition to the static imports alongside updateRepoCardPosition,
batchUpdateRepoCardOrders, swapStatusListPositions, and
batchUpdateStatusListPositions, and then remove the inline await import and call
updateStatusListPosition(activeStatus.id, targetRow, targetCol) directly; update
the import list (where those other actions are imported) and simplify the try
block to call updateStatusListPosition by name.

In `@src/hooks/board/useKanbanUndo.ts`:
- Around line 75-121: The handleUndo callback closes over history and
columnHistory so its identity changes whenever those arrays change; replace the
collapsing dependency by keeping stable refs for the stacks (e.g., historyRef
and columnHistoryRef) that mirror state and are written to inside the
push/update logic, then have handleUndo read from historyRef.current and
columnHistoryRef.current so it can be memoized without history/columnHistory in
its dependency array; update the push callbacks that call
setHistory/setColumnHistory to also update the corresponding ref, and ensure the
useEffect that registers the keydown listener uses the stable handleUndo (so it
no longer tears down/re-registers on each stack push).

Comment on lines +436 to +457
} else {
// Move to different column
const updatedCards = cards.map((c) =>
c.id === activeCard.id ? { ...c, statusId: targetStatusId } : c,
)
dispatch(setRepoCards(updatedCards))

try {
const targetColumnCards = updatedCards.filter(
(c) => c.statusId === targetStatusId,
)
const newOrder = targetColumnCards.findIndex(
(c) => c.id === activeCard.id,
)
await updateRepoCardPosition(activeCard.id, targetStatusId, newOrder)
} catch (error) {
Sentry.captureException(error, {
tags: { action: 'syncCardPosition' },
})
dispatch(setRepoCards(cards))
}
}
Copy link

@coderabbitai coderabbitai bot Feb 20, 2026

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's check the full context of useKanbanDnD.ts around the mentioned lines
wc -l src/hooks/board/useKanbanDnD.ts

Repository: laststance/gitbox

Length of output: 96


🏁 Script executed:

# Read the relevant section with broader context to understand the handler
sed -n '400,480p' src/hooks/board/useKanbanDnD.ts

Repository: laststance/gitbox

Length of output: 2518


🏁 Script executed:

# Search for updateRepoCardPosition usage across the codebase
rg -n 'updateRepoCardPosition' --type=ts

Repository: laststance/gitbox

Length of output: 793


🏁 Script executed:

# Search for how over.id is used in this file
rg -n 'over\.id' src/hooks/board/useKanbanDnD.ts -B2 -A2

Repository: laststance/gitbox

Length of output: 1203


🏁 Script executed:

# Look for any similar order calculation patterns with drop position
rg -n 'findIndex.*statusId' --type=ts -C3

Repository: laststance/gitbox

Length of output: 43


🏁 Script executed:

# Get the full handleDragEnd function to see the complete logic
sed -n '333,483p' src/hooks/board/useKanbanDnD.ts

Repository: laststance/gitbox

Length of output: 4588


🏁 Script executed:

# Check if there are any comments or TODOs about cross-column ordering
rg -n 'cross.column|different column' src/hooks/board/useKanbanDnD.ts -B2 -A2

Repository: laststance/gitbox

Length of output: 262


🏁 Script executed:

# Check the updateRepoCardPosition function signature and implementation
sed -n '382,410p' src/lib/actions/board.ts

Repository: laststance/gitbox

Length of output: 657


Cross-column card move does not respect visual drop position — card lands at array order instead of where dropped.

When moving a card to a different column (lines 437-451), the code ignores over.id and instead places the card at its natural position in the filtered array. Compare this to same-column reordering (lines 413-428), which explicitly uses over.id to calculate the visual drop position via findIndex.

For cross-column moves, pass over.id to determine the drop position, similar to the same-column logic. If over.id is a card in the target column, use it to calculate the insertion point. If over.id is a column/zone, append to the end.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/board/useKanbanDnD.ts` around lines 436 - 457, Cross-column moves
ignore the visual drop target and instead compute newOrder by finding activeCard
in the filtered array; update this to respect over.id like the same-column path:
in the else branch (where activeCard, cards, targetStatusId are used and
dispatch(setRepoCards(...)) is called) compute targetColumnCards then determine
newOrder using over.id — if over.id corresponds to a card in targetColumnCards
use findIndex(c => c.id === over.id) (and insert before/after as your
same-column logic expects), and if over.id is a column/zone treat it as append
(newOrder = targetColumnCards.length); then call
updateRepoCardPosition(activeCard.id, targetStatusId, newOrder) and keep the
existing Sentry + rollback behavior.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is pre-existing behavior that was moved verbatim from KanbanBoard into the hook — not introduced by this refactoring PR. The cross-column ordering logic was written this way intentionally: when moving a card to a different column, over.id may refer to a column (not a card), so findIndex against cards would return -1. The current approach appends to the natural position in the target column. Changing DnD behavior in a pure refactoring PR carries regression risk. Will track as a separate enhancement issue if needed.

Copy link

Choose a reason for hiding this comment

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

@ryota-murakami You're absolutely right — preserving exact behavior is the correct approach for a refactoring PR. Mixing behavior changes with structural changes would make it harder to identify the source of any regressions.

If you'd like to track this as a future enhancement, I can help you create a follow-up issue with the context and suggested approach. Just let me know!


🧠 Learnings used
Learnt from: CR
Repo: laststance/gitbox PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-19T14:45:15.429Z
Learning: Applies to **/*.{tsx,ts} : Use React 19.2 features: `useOptimistic`, `useActionState`, `use` API, and Form Actions for state management and async operations

Learnt from: CR
Repo: laststance/gitbox PR: 0
File: CLAUDE.md:0-0
Timestamp: 2026-02-17T14:50:11.012Z
Learning: Applies to **/{components,app}/**/*.{ts,tsx} : Use React 19.2 APIs: `useOptimistic`, `useActionState`, `use` API, and Form Actions instead of traditional state management patterns

- useCommentState: convert unreachable try/catch to result.success pattern
  (server actions return ActionResult, never throw)
- useCommentState: add rollback + toast on all 3 handlers failure path
- useCommentState: remove duplicate Sentry reporting (server already reports)
- useKanbanUndo: guard Z-key handler with !ctrlKey && !metaKey && !altKey
  to avoid conflict with browser Ctrl+Z undo
@github-actions
Copy link

github-actions bot commented Feb 20, 2026

🧪 E2E Coverage Report (Sharded: 12 parallel jobs)

Metric Coverage
Lines 94.53%
Functions 17.98%
Branches 17.45%
Statements 31.03%

📊 Full report available in workflow artifacts

…tion

Address CodeRabbit nitpick: align updateStatusListPosition import
with other board actions that are already statically imported.
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (1)
src/hooks/board/useKanbanDnD.ts (1)

208-293: Consider useOptimistic for the optimistic-dispatch/DB-sync pattern per project guidelines.

The sub-handlers (handleNewRowDrop, handleColumnInsertDrop, handleColumnSwap, and the card paths in handleDragEnd) all share the same pattern: optimistically update Redux state → fire async DB call → rollback on error. This is the exact use-case useOptimistic was designed for.

Per project guidelines, useOptimistic is the preferred approach for async state management with optimistic updates. Given the Redux dependency, one practical option is to keep Redux as the source of truth for persistence but use useOptimistic to manage the transient "in-flight" visual state, reducing the need for manual rollback boilerplate.

This is a broader architectural change that doesn't need to block this PR, but would be good to track as a follow-up.

Based on learnings: "Use React 19.2 features: useOptimistic, useActionState, use API, and Form Actions for state management and async operations."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/board/useKanbanDnD.ts` around lines 208 - 293, The handlers
handleNewRowDrop and handleColumnInsertDrop (and similarly handleColumnSwap and
the card-related branches in handleDragEnd) duplicate an optimistic-update →
async DB call → manual rollback pattern; replace this boilerplate by using
React's useOptimistic to manage the transient optimistic state while keeping
Redux as the persistent source of truth: create a local optimistic state keyed
by status IDs with useOptimistic, update that optimistic state immediately in
the handlers (instead of directly dispatching setStatusLists), call
updateStatusListPosition / batchUpdateStatusListPositions, then on success
dispatch the Redux commit (setStatusLists) and on failure revert the optimistic
state and capture the error (Sentry.captureException) — this centralizes
rollback logic, removes manual dispatch rollback in the catch blocks, and apply
the same pattern for handleColumnSwap and the card move branches in
handleDragEnd.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/hooks/board/useKanbanUndo.ts`:
- Around line 75-120: handleUndo currently performs optimistic Redux updates and
pops history before the DB write completes, so failures leave Redux and DB out
of sync; capture the pre-undo state and restore it on DB failure. Modify
useKanbanUndo to accept current board state via UseKanbanUndoParams (e.g.,
currentStatuses and currentCards), then in handleUndo save the preDispatchState
(for columns: currentStatuses or the current setStatusLists state; for cards:
currentCards or current setRepoCards state) before calling dispatch and before
slicing history; if batchUpdateStatusListPositions or batchUpdateRepoCardOrders
rejects, in the .catch() restore Redux by dispatching
setStatusLists(preDispatchState) or setRepoCards(preDispatchState) and restore
the history stack (push the popped item back via setColumnHistory / setHistory)
in addition to the existing Sentry/toast handling. Ensure you reference the
existing symbols: handleUndo, batchUpdateStatusListPositions,
batchUpdateRepoCardOrders, dispatch(setStatusLists), dispatch(setRepoCards),
setColumnHistory, setHistory.

---

Duplicate comments:
In `@src/hooks/board/useKanbanUndo.ts`:
- Around line 136-145: The key handler in useKanbanUndo correctly restricts undo
to bare Z (no Ctrl/Meta/Alt) to avoid browser conflicts; keep the existing guard
(the conditional checking event.key === 'z'/'Z' plus !event.ctrlKey,
!event.metaKey, !event.altKey), keep event.preventDefault() and the handleUndo()
call as-is in the keydown handler so no changes are needed to the useKanbanUndo
logic.

---

Nitpick comments:
In `@src/hooks/board/useKanbanDnD.ts`:
- Around line 208-293: The handlers handleNewRowDrop and handleColumnInsertDrop
(and similarly handleColumnSwap and the card-related branches in handleDragEnd)
duplicate an optimistic-update → async DB call → manual rollback pattern;
replace this boilerplate by using React's useOptimistic to manage the transient
optimistic state while keeping Redux as the persistent source of truth: create a
local optimistic state keyed by status IDs with useOptimistic, update that
optimistic state immediately in the handlers (instead of directly dispatching
setStatusLists), call updateStatusListPosition / batchUpdateStatusListPositions,
then on success dispatch the Redux commit (setStatusLists) and on failure revert
the optimistic state and capture the error (Sentry.captureException) — this
centralizes rollback logic, removes manual dispatch rollback in the catch
blocks, and apply the same pattern for handleColumnSwap and the card move
branches in handleDragEnd.

Comment on lines +408 to +435
// Save card history for undo
pushCardHistory(cards)

if (activeCard.statusId === targetStatusId) {
// Reorder within same column
const columnCards = cardsByStatus[targetStatusId] ?? EMPTY_CARDS
const oldIndex = columnCards.findIndex((c) => c.id === active.id)
const newIndex = columnCards.findIndex((c) => c.id === over.id)

if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
const reordered = arrayMove(columnCards, oldIndex, newIndex)
const otherCards = cards.filter((c) => c.statusId !== targetStatusId)
dispatch(setRepoCards([...otherCards, ...reordered]))

const updates = reordered.map((card, index) => ({
id: card.id,
statusId: targetStatusId,
order: index,
}))
try {
await batchUpdateRepoCardOrders(updates)
} catch (error) {
Sentry.captureException(error, {
tags: { action: 'syncCardOrder' },
})
dispatch(setRepoCards(cards))
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

pushCardHistory fires before the no-op guard, creating phantom undo states.

pushCardHistory(cards) is called at line 409 unconditionally, before the oldIndex !== newIndex check at line 417. When a card is dropped on its current position (or not found in columnCards), no board state changes — but a snapshot is pushed. The user then needs two Z presses to undo one real drag: the first silently consumes the phantom entry.

🔧 Proposed fix — move `pushCardHistory` inside actual-move branches
-      // Save card history for undo
-      pushCardHistory(cards)
-
       if (activeCard.statusId === targetStatusId) {
         // Reorder within same column
         const columnCards = cardsByStatus[targetStatusId] ?? EMPTY_CARDS
         const oldIndex = columnCards.findIndex((c) => c.id === active.id)
         const newIndex = columnCards.findIndex((c) => c.id === over.id)

         if (oldIndex !== -1 && newIndex !== -1 && oldIndex !== newIndex) {
+          pushCardHistory(cards)
           const reordered = arrayMove(columnCards, oldIndex, newIndex)
           ...
         }
       } else {
         // Move to different column
+        pushCardHistory(cards)
         const updatedCards = cards.map(...)
         ...
       }

Comment on lines +75 to +120
const handleUndo = useCallback(() => {
// Try column undo first, then card undo
if (columnHistory.length > 0) {
const previousState = columnHistory[columnHistory.length - 1]
if (previousState) {
dispatch(setStatusLists(previousState))
setColumnHistory((prev) => prev.slice(0, -1))
toast.success('Column order restored')

// P1-8: Sync reverted column positions to DB
const updates = previousState.map((s) => ({
id: s.id,
gridRow: s.gridRow,
gridCol: s.gridCol,
}))
batchUpdateStatusListPositions(updates).catch((error) => {
Sentry.captureException(error, {
tags: { action: 'undoColumnPositions' },
})
toast.error('Failed to sync undo to database')
})
}
return
}

if (history.length > 0) {
const previousState = history[history.length - 1]
if (previousState) {
dispatch(setRepoCards(previousState))
setHistory((prev) => prev.slice(0, -1))
toast.success('Card operation undone')

// P1-8: Sync reverted card positions to DB
const updates = previousState.map((c, index) => ({
id: c.id,
statusId: c.statusId,
order: c.order ?? index,
}))
batchUpdateRepoCardOrders(updates).catch((error) => {
Sentry.captureException(error, {
tags: { action: 'undoCardPositions' },
})
toast.error('Failed to sync undo to database')
})
}
}
Copy link

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Undo DB-sync failure leaves Redux and database permanently out of sync.

Both undo paths optimistically update Redux state and pop the history stack before the DB write completes. If batchUpdateStatusListPositions or batchUpdateRepoCardOrders throws, the .catch() only fires Sentry + toast — there is no dispatch(setStatusLists(...)) / dispatch(setRepoCards(...)) call to roll back the optimistic update. On the next page load the database wins and the board silently reverts, which is confusing.

Compare with handleColumnSwap in useKanbanDnD.ts (line 319–326), which captures statuses before dispatch and restores it in the catch.

The fix requires capturing the current state before dispatching, then restoring it on failure. The cleanest approach is to extend UseKanbanUndoParams with the current board state:

🔧 Proposed fix
 interface UseKanbanUndoParams {
   /** Redux dispatch function */
   dispatch: AppDispatch
+  /** Current status lists for rollback on undo failure */
+  statuses: StatusListDomain[]
+  /** Current repo cards for rollback on undo failure */
+  cards: RepoCardForRedux[]
 }
 export function useKanbanUndo(
   params: UseKanbanUndoParams,
 ): UseKanbanUndoReturn {
-  const { dispatch } = params
+  const { dispatch, statuses, cards } = params
     if (previousState) {
+      const preUndoState = statuses
       dispatch(setStatusLists(previousState))
       setColumnHistory((prev) => prev.slice(0, -1))
       toast.success('Column order restored')
       batchUpdateStatusListPositions(updates).catch((error) => {
         Sentry.captureException(error, { tags: { action: 'undoColumnPositions' } })
+        dispatch(setStatusLists(preUndoState))
+        setColumnHistory((prev) => [...prev, previousState])
         toast.error('Failed to sync undo to database')
       })
     if (previousState) {
+      const preUndoCards = cards
       dispatch(setRepoCards(previousState))
       setHistory((prev) => prev.slice(0, -1))
       toast.success('Card operation undone')
       batchUpdateRepoCardOrders(updates).catch((error) => {
         Sentry.captureException(error, { tags: { action: 'undoCardPositions' } })
+        dispatch(setRepoCards(preUndoCards))
+        setHistory((prev) => [...prev, previousState])
         toast.error('Failed to sync undo to database')
       })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/hooks/board/useKanbanUndo.ts` around lines 75 - 120, handleUndo currently
performs optimistic Redux updates and pops history before the DB write
completes, so failures leave Redux and DB out of sync; capture the pre-undo
state and restore it on DB failure. Modify useKanbanUndo to accept current board
state via UseKanbanUndoParams (e.g., currentStatuses and currentCards), then in
handleUndo save the preDispatchState (for columns: currentStatuses or the
current setStatusLists state; for cards: currentCards or current setRepoCards
state) before calling dispatch and before slicing history; if
batchUpdateStatusListPositions or batchUpdateRepoCardOrders rejects, in the
.catch() restore Redux by dispatching setStatusLists(preDispatchState) or
setRepoCards(preDispatchState) and restore the history stack (push the popped
item back via setColumnHistory / setHistory) in addition to the existing
Sentry/toast handling. Ensure you reference the existing symbols: handleUndo,
batchUpdateStatusListPositions, batchUpdateRepoCardOrders,
dispatch(setStatusLists), dispatch(setRepoCards), setColumnHistory, setHistory.

@ryota-murakami ryota-murakami merged commit c967c89 into main Feb 20, 2026
20 checks passed
@ryota-murakami ryota-murakami deleted the feat/bulk-issues-20260220 branch February 20, 2026 14:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment