Skip to content

Commit ce6363e

Browse files
committed
fix: tighten drag & drop edge cases
Reset drag on dialog open, key press, and stale-card refresh so a swallowed mouse-release can never silently move a card later. Drop resolves the card by ID (moveCard) rather than by current selection, bounds-check dragCol in renderFooter, ignore non-left releases, and suppress jitter-within-card so double-click attach still works. Assisted-By: Claude
1 parent f6eb92b commit ce6363e

4 files changed

Lines changed: 132 additions & 10 deletions

File tree

docs/features/board/index.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,11 +49,12 @@ Requirements: `tmux` and `git` must be installed.
4949
| `o` | Open the card's worktree in `$DOCKER_AGENT_BOARD_EDITOR` (`code`) |
5050
| `s` | Open an interactive shell in the card's worktree |
5151
| `[` / `]` | Move the card back / forward |
52+
| `1`-`9` | Move the card to column N |
5253
| `x` | Delete the card, its session, worktree, and branch |
5354
| `p` | Manage projects (add, edit, reorder, remove) |
5455
| `e` | Edit the selected column's prompt |
5556
| `←↓↑→` `hjkl` | Navigate |
56-
| mouse | Click selects, double-click attaches, wheel scrolls |
57+
| mouse | Click selects, double-click attaches, drag moves, wheel scrolls |
5758
| `?` | Help |
5859
| `q` | Quit (agents keep running) |
5960

pkg/board/tui/tui.go

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"image/color"
77
"os"
88
"os/exec"
9+
"slices"
910
"strings"
1011
"time"
1112

@@ -254,6 +255,7 @@ func newModel(app *board.App, refresh chan struct{}) *model {
254255

255256
// openDialog installs a dialog and runs its init command.
256257
func (m *model) openDialog(d dialog) tea.Cmd {
258+
m.resetDrag() // the dialog captures the release: the drag cannot finish
257259
m.dialog = d
258260
return d.Init()
259261
}
@@ -269,6 +271,15 @@ func (m *model) reload() {
269271
m.projectColors[p.Name] = projectColorAt(i)
270272
}
271273
m.clampSelection()
274+
// A refresh can remove the dragged card or shrink the column list;
275+
// re-validate so a drop cannot act on stale state.
276+
if m.dragCardID != "" {
277+
if m.cardByID(m.dragCardID) == nil {
278+
m.resetDrag()
279+
} else if m.dragCol >= len(m.columns) {
280+
m.dragCol = -1
281+
}
282+
}
272283
}
273284

274285
// groupCards buckets cards by column, in board order. A card whose column
@@ -313,6 +324,18 @@ func (m *model) selectedCard() *board.Card {
313324
return cards[m.selRow]
314325
}
315326

327+
// cardByID finds a card anywhere on the board.
328+
func (m *model) cardByID(id string) *board.Card {
329+
for _, cards := range m.cards {
330+
for _, c := range cards {
331+
if c.ID == id {
332+
return c
333+
}
334+
}
335+
}
336+
return nil
337+
}
338+
316339
func (m *model) Init() tea.Cmd {
317340
return tea.Batch(m.waitRefresh(), m.scheduleTick())
318341
}
@@ -522,6 +545,10 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
522545
}
523546

524547
func (m *model) handleKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
548+
// A key press cancels any drag in progress (esc cancels a drag) and
549+
// keeps the state clean across paths that lose the release event, like
550+
// tea.ExecProcess (attach, shell) leaving mouse mode.
551+
m.resetDrag()
525552
switch {
526553
case key.Matches(msg, keys.Quit):
527554
return m, tea.Quit
@@ -712,12 +739,19 @@ func (m *model) handleClick(msg tea.MouseClickMsg) (tea.Model, tea.Cmd) {
712739
}
713740

714741
// handleMotion tracks a pressed card being dragged. Cell-motion mode only
715-
// reports motion while a button is held, so any motion after a card press
742+
// reports motion while a button is held, so motion after a card press
716743
// means a drag; the column under the pointer becomes the drop target.
717744
func (m *model) handleMotion(msg tea.MouseMotionMsg) {
718745
if m.dragCardID == "" || msg.Button != tea.MouseLeft {
719746
return
720747
}
748+
// Jitter within the pressed card stays a click (double-click keeps
749+
// working): the drag starts once the pointer leaves the card.
750+
if !m.dragging {
751+
if col, row, ok := m.cardAt(msg.X, msg.Y); ok && col == m.selCol && row == m.selRow {
752+
return
753+
}
754+
}
721755
m.dragging = true
722756
if col, ok := m.columnAt(msg.X, msg.Y); ok {
723757
m.dragCol = col
@@ -730,19 +764,31 @@ func (m *model) handleMotion(msg tea.MouseMotionMsg) {
730764
// column moves it there. A release without prior motion is a plain click,
731765
// already handled by handleClick.
732766
func (m *model) handleRelease(msg tea.MouseReleaseMsg) (tea.Model, tea.Cmd) {
733-
wasDragging, dst := m.dragging, m.dragCol
734-
m.dragCardID, m.dragging, m.dragCol = "", false, -1
767+
if msg.Button != tea.MouseLeft {
768+
return m, nil
769+
}
770+
cardID, wasDragging, dst := m.dragCardID, m.dragging, m.dragCol
771+
m.resetDrag()
735772
if !wasDragging {
736773
return m, nil
737774
}
738775
m.lastClickCard = "" // a completed drag must not arm double-click
739776
if col, ok := m.columnAt(msg.X, msg.Y); ok {
740777
dst = col
741778
}
742-
cmd := m.moveCardTo(dst)
779+
cmd := m.moveCard(cardID, dst)
743780
return m, cmd
744781
}
745782

783+
// resetDrag abandons any drag in progress. Called whenever a drag can no
784+
// longer complete cleanly: a dialog opening (it captures the release), a
785+
// key press, or the dragged card disappearing on a refresh.
786+
func (m *model) resetDrag() {
787+
m.dragCardID = ""
788+
m.dragging = false
789+
m.dragCol = -1
790+
}
791+
746792
// handleWheel moves the selection through the column under the cursor, so
747793
// scrolling anywhere on a column walks its cards (the scroll window follows
748794
// the selection). Wheel events outside the columns area are ignored.
@@ -787,13 +833,25 @@ func (m *model) createCard(project board.Project, prompt string) tea.Cmd {
787833

788834
// moveCardTo moves the selected card to the column at index dst.
789835
func (m *model) moveCardTo(dst int) tea.Cmd {
790-
card := m.selectedCard()
791-
if card == nil || dst == m.selCol || dst < 0 || dst >= len(m.columns) {
836+
if card := m.selectedCard(); card != nil {
837+
return m.moveCard(card.ID, dst)
838+
}
839+
return nil
840+
}
841+
842+
// moveCard moves a card — by ID, so a board refresh mid-drag cannot
843+
// redirect the move to another card — to the column at index dst. Moving a
844+
// card to the column it already occupies is a no-op.
845+
func (m *model) moveCard(cardID string, dst int) tea.Cmd {
846+
if dst < 0 || dst >= len(m.columns) {
792847
return nil
793848
}
794849
colID := m.columns[dst].ID
850+
if slices.ContainsFunc(m.cards[colID], func(c *board.Card) bool { return c.ID == cardID }) {
851+
return nil
852+
}
795853
return func() tea.Msg {
796-
if err := m.app.MoveCard(card.ID, colID); err != nil {
854+
if err := m.app.MoveCard(cardID, colID); err != nil {
797855
return flashMsg{text: err.Error(), isErr: true}
798856
}
799857
return cardMovedMsg{colIdx: dst}

pkg/board/tui/tui_test.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,68 @@ func TestDragBackToOriginIsANoop(t *testing.T) {
158158
assert.False(t, m.dragging)
159159
}
160160

161+
func TestDragJitterWithinCardStaysAClick(t *testing.T) {
162+
t.Parallel()
163+
164+
m := dragTestModel()
165+
_, _ = m.handleClick(tea.MouseClickMsg{X: 5, Y: 5, Button: tea.MouseLeft})
166+
167+
// Motion within the pressed card does not start a drag…
168+
m.handleMotion(tea.MouseMotionMsg{X: 6, Y: 6, Button: tea.MouseLeft})
169+
assert.False(t, m.dragging)
170+
171+
// …so the release is a plain click and double-click stays armed.
172+
_, cmd := m.handleRelease(tea.MouseReleaseMsg{X: 6, Y: 6, Button: tea.MouseLeft})
173+
assert.Nil(t, cmd)
174+
assert.Equal(t, "a", m.lastClickCard)
175+
}
176+
177+
func TestNonLeftReleaseDoesNotDrop(t *testing.T) {
178+
t.Parallel()
179+
180+
m := dragTestModel()
181+
_, _ = m.handleClick(tea.MouseClickMsg{X: 5, Y: 5, Button: tea.MouseLeft})
182+
m.handleMotion(tea.MouseMotionMsg{X: 65, Y: 5, Button: tea.MouseLeft})
183+
184+
// A stray non-left release mid-drag neither drops nor cancels.
185+
_, cmd := m.handleRelease(tea.MouseReleaseMsg{X: 65, Y: 5, Button: tea.MouseRight})
186+
assert.Nil(t, cmd)
187+
assert.True(t, m.dragging)
188+
}
189+
190+
func TestDragCancelledByDialogAndKeys(t *testing.T) {
191+
t.Parallel()
192+
193+
// A dialog opening mid-drag captures the release: the drag must not
194+
// survive, or the next click would silently move a card.
195+
m := dragTestModel()
196+
_, _ = m.handleClick(tea.MouseClickMsg{X: 5, Y: 5, Button: tea.MouseLeft})
197+
m.handleMotion(tea.MouseMotionMsg{X: 65, Y: 5, Button: tea.MouseLeft})
198+
_ = m.openDialog(newHelpDialog())
199+
assert.False(t, m.dragging)
200+
assert.Empty(t, m.dragCardID)
201+
202+
// A key press cancels a drag too (esc cancels).
203+
m = dragTestModel()
204+
_, _ = m.handleClick(tea.MouseClickMsg{X: 5, Y: 5, Button: tea.MouseLeft})
205+
m.handleMotion(tea.MouseMotionMsg{X: 65, Y: 5, Button: tea.MouseLeft})
206+
_, _ = m.handleKey(tea.KeyPressMsg{Code: tea.KeyEscape})
207+
assert.False(t, m.dragging)
208+
}
209+
210+
func TestMoveCardResolvesByID(t *testing.T) {
211+
t.Parallel()
212+
213+
m := dragTestModel()
214+
215+
// Moving a card to the column it already occupies is a no-op, wherever
216+
// the selection points (the drop path resolves the card by ID).
217+
assert.Nil(t, m.moveCard("c", 1), "card c already sits in column done")
218+
assert.NotNil(t, m.moveCard("c", 0))
219+
assert.Nil(t, m.moveCard("a", -1))
220+
assert.Nil(t, m.moveCard("a", 2))
221+
}
222+
161223
func TestReleaseWithoutMotionIsAClick(t *testing.T) {
162224
t.Parallel()
163225

pkg/board/tui/view.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -412,9 +412,10 @@ func (m *model) renderStatus(status board.CardStatus, width int) string {
412412

413413
func (m *model) renderFooter() string {
414414
if m.dragging {
415-
if m.dragCol >= 0 && m.dragCol != m.selCol {
415+
if m.dragCol >= 0 && m.dragCol < len(m.columns) && m.dragCol != m.selCol {
416+
const hint = " Release to move the card to "
416417
name := strings.TrimSpace(m.columns[m.dragCol].Emoji + " " + m.columns[m.dragCol].Name)
417-
return styles.SuccessStyle.Render(" Release to move the card to " + toolcommon.TruncateText(sanitize(name), max(m.width-31, 1)))
418+
return styles.SuccessStyle.Render(hint + toolcommon.TruncateText(sanitize(name), max(m.width-len(hint)-1, 1)))
418419
}
419420
return styles.MutedStyle.Render(" Drop the card on another column to move it")
420421
}

0 commit comments

Comments
 (0)