Skip to content

fix(desktop/tasks): drag-to-reorder visibility, persistence, and end-of-drag cleanup#7185

Open
eulicesl wants to merge 6 commits intoBasedHardware:mainfrom
eulicesl:fix/desktop-tasks-drag-reorder
Open

fix(desktop/tasks): drag-to-reorder visibility, persistence, and end-of-drag cleanup#7185
eulicesl wants to merge 6 commits intoBasedHardware:mainfrom
eulicesl:fix/desktop-tasks-drag-reorder

Conversation

@eulicesl
Copy link
Copy Markdown

@eulicesl eulicesl commented May 5, 2026

Summary

Fixes three independent bugs in the macOS Tasks page drag-to-reorder UX, all in desktop/Desktop/Sources/MainWindow/Pages/TasksPage.swift:

  • Drag handle was invisible. .onHover was attached to taskRowContent, so hovering on the handle's frame (a sibling of taskRowContent inside the outer HStack) didn't flip isHovering — the handle stayed at .foregroundColor(.clear). User reported "the handle doesn't appear when the cursor is near, only when the cursor is over the text." Move .onHover to the outer body so it fires for hover over both the handle area and the content area.
  • Reorder didn't persist visually. moveTask only wrote sortOrder updates to store.incompleteTasks. With any non-status filter active (the default last7Days filter qualifies), recomputeDisplayCaches picks displayTasks from filteredFromDatabase, not from the store, so the writes missed entirely (firstIndex returned nil). And even when they landed, recomputeAllCaches was scheduling loadFilteredTasksFromDatabase() which clobbered local writes with stale SQLite rows before the debounced scheduleSortOrderSync could persist the new sortOrder. Apply the new sortOrders to every source array (incomplete + filteredFromDatabase + searchResults), and use the existing suppressDatabaseRequery flag during the debounce window — pattern matches clearTodayDeadlinesForIncompleteTasks.
  • Dragged row never dimmed in flight. TaskRow.onDrag never invoked the parent's onDragStarted callback, the optional was silently nil at the call site, and there was no consumer of viewModel.draggedTaskId to apply opacity. Wire the dim end-to-end with non-optional callbacks (silent-nil was the original bug — the type system now prevents repeats), and signal end-of-drag via an NSItemProvider subclass that fires a callback in deinit so dim clears on every drag-end path (drop, dead-space drop, off-window drop, escape cancel) — NSEvent.addLocalMonitor/addGlobalMonitor for .leftMouseUp were tried first and don't fire from inside AppKit's drag modal loop.

Changes

  • Edited desktop/Desktop/Sources/MainWindow/Pages/TasksPage.swift
    • TaskRow.body — relocated .onHover from taskRowContent so the drag handle's frame is part of the hover hit region.
    • TasksViewModel.moveTask — replaced single-array sortOrder mutation with applyOrder(to:) helper applied to store.incompleteTasks, filteredFromDatabase, and searchResults. Swapped recomputeAllCaches() for suppressDatabaseRequery = true; recomputeDisplayCaches() to block the async SQLite requery during the debounce window.
    • TasksViewModel.syncSortOrders — added defer { suppressDatabaseRequery = false } so the flag is reset after SQLite is fresh, regardless of error/cancellation.
    • TaskRowonDragStarted: (String) -> Void and onDragEnded: () -> Void are now non-optional with no-op defaults; isBeingDragged: Bool drives .opacity(0.4) on the row body with a 120ms ease-in-out.
    • TaskCategorySection — forwards draggedTaskId from the view model so it can compute isBeingDragged: draggedTaskId == task.id per row; mirrored non-optional callback shape.
    • TaskDragDropModifier — removed dead var onDragStarted declaration (modifier handles drop targets only, not drag sources).
    • Added TaskDragItemProvider: NSItemProvider — replaces a prior local+global NSEvent monitor approach that didn't fire for aborted drags. Subclass fires its onEnd callback in deinit, which AppKit triggers when the drag session ends on every path. The deinit hops to main before mutating @Published state since AppKit may release the provider off-main.
  • Edited desktop/CHANGELOG.json — added user-facing entry under unreleased.

Why

Three separate bugs that compound: the handle is invisible, so the user can't grab it; if they get past that, dropping doesn't persist; if it does persist, the dragged row stays dimmed forever after an aborted drop. All three are existing bugs against in-tree code; the changes are additive/defensive.

Test plan

  • xcrun swift build -c debug --package-path Desktop compiles cleanly on upstream/main after cherry-pick (57.87s).
  • No new debug logs landed in the diff (verified via git diff upstream/main..HEAD | grep '^+.*log("').
  • No fork-only patches leaked (verified scope — only TasksPage.swift + CHANGELOG.json).
  • All six manual interactions verified locally — see "Live test evidence" below.
  • Codemagic release build succeeds (will verify on merge).

Live test evidence

Tested in a named bundle omi-drag-reorder (OMI_APP_NAME="omi-drag-reorder" ./run.sh --yolo) on macOS:

  • L1 — Drag handle visibility: hovering anywhere over a row reveals the handle within the row's full frame, including the leftmost area where the handle sits. Confirmed by user after the .onHover relocation.
  • L2 — Drag start dim: mid-drag screenshot showed the source row at ~40% opacity within ~120ms of grab; surrounding rows unaffected. Drop indicator line rendered correctly between rows.
  • L3 — Drop on valid target: order updates immediately and persists. Verified via runtime log REORDER: moveTask(<id>, toIndex: N, inCategory: <cat>) plus visible reorder. Captured writes incomplete=0 filtered=N confirming the write landed in the right backing array when filters are active.
  • L4 — Drop in dead space inside window: dragged row un-dimmed within ~100ms of mouse release; no stuck state across multiple repeats.
  • L5 — Drop outside window: dragged onto desktop, released; row un-dimmed within ~100ms. Confirms NSItemProvider deinit fires for off-window outcomes (the case addGlobalMonitorForEvents failed to catch).
  • L6 — Rapid drags: three successful drops in quick succession on different rows; no leftover dim, no flicker, all writes landed correctly.
  • L7 — Persist across restart: reordered, Cmd+Q, open /Applications/omi-drag-reorder.app; the new order was preserved on relaunch.

Risk and rollback

None significant. All three changes are additive or defensive against existing in-tree code:

  • The .onHover relocation is a single-modifier move with identical handler body.
  • The moveTask change adds writes to two more arrays (idempotent if a task isn't in them) and reuses the existing suppressDatabaseRequery flag with the same lifecycle pattern as clearTodayDeadlinesForIncompleteTasks.
  • The TaskDragItemProvider subclass produces identical drop-target behavior (still registers the task id as NSString); only adds a deinit-time callback.
  • Non-optional callback defaults are no-ops ({ _ in } / {}), so any caller that didn't supply them keeps working.

Rollback: revert the three commits — they're independent and ordered by concern.

Notes for review

  • Cherry-picked from a private fork branch onto upstream/main; verified clean (no debug logs, no fork-only patches, scope confined to two files). Original commits authored 2026-05-04/05.
  • Three commits intentionally — separable concerns, separable risk profiles. Happy to squash if reviewer prefers.
  • The defer { suppressDatabaseRequery = false } in syncSortOrders is a no-op for the two other callers (incrementIndent, decrementIndent) since they never set the flag. Safe.

cc @kodjima33

🤖 Generated with Claude Code

eulicesl and others added 3 commits May 5, 2026 17:20
…s on cursor approach

The drag handle is rendered in the outer HStack as a sibling of
taskRowContent, but the .onHover that drives `isHovering` (and therefore
the handle's visibility) was attached to taskRowContent, not the body.
Result: hovering over the handle's frame did not flip isHovering, so the
handle stayed at .foregroundColor(.clear) and the user could not see or
grab it. Hovering over the text region worked because that is where the
hover trigger actually lived.

Move .onHover to the outer body so it fires for hover anywhere within
the row, including the handle area. Same handler body — no behavior
change for the existing cursor / onHover? callback, just a wider hit
region.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…suppress DB requery during move

moveTask only wrote sortOrder updates to store.incompleteTasks. But
recomputeDisplayCaches picks the displayTasks source in priority order:
searchResults → filteredFromDatabase → store.incompleteTasks. With any
non-status filter active (the default last7Days filter qualifies), the
displayed tasks come from filteredFromDatabase, not from the store, so
the writes missed entirely (firstIndex returned nil) and the row
visually snapped back to its old position.

Apply the new sortOrders to every source array the task could live in.
Each @published reassignment fires once; recomputeDisplayCaches folds
them into categorizedTasks for the next render.

Even with the writes landing, recomputeAllCaches was scheduling
loadFilteredTasksFromDatabase() asynchronously when filters were active.
That requery clobbered filteredFromDatabase with SQLite rows that did
not yet have the new sortOrder (scheduleSortOrderSync writes to SQLite
+500ms after the move). Use the existing suppressDatabaseRequery flag
during the debounce window — pattern matches clearTodayDeadlinesForIncompleteTasks.
The flag is reset via `defer` inside syncSortOrders once SQLite is fresh,
so the next requery returns valid data.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…anup

The drag-to-reorder UI was 95% built but the dragged row never visually
dimmed in flight: TaskRow.onDrag never invoked the parent's
onDragStarted callback, and even if it had, the optional callback was
silently nil at the only call site that mattered.

Wire the dim end-to-end and make the callback contract explicit:
- TaskRow exposes onDragStarted/onDragEnded/isBeingDragged. The two
  callbacks are non-optional with no-op defaults — silent-nil at the
  parent was the original bug we are fixing, and the type system now
  prevents repeats.
- TaskCategorySection forwards draggedTaskId from the view model so it
  can compute isBeingDragged per row.
- Apply .opacity(0.4) to the dragged row body with a 120ms ease-in-out.
- Drop the dead onDragStarted on TaskDragDropModifier (the modifier
  handles drop targets only, not drag sources).

End-of-drag detection is the harder half. Items 3 and 4 of the
verification checklist (drop in dead space, drop outside the window)
require firing onDragEnded even when no drop target accepts the drop.
A first attempt with NSEvent.addLocalMonitorForEvents and
addGlobalMonitorForEvents for .leftMouseUp did not fire — AppKit drains
the trailing mouseUp through its modal drag loop and skips the standard
event monitors, so the dragged row would stay dimmed forever on aborted
drags.

Replace it with an NSItemProvider subclass that fires a callback in
deinit. AppKit releases the provider when the drag session ends on every
path (drop, dead-space drop, off-window drop, escape cancel), so deinit
is the only signal that fires uniformly for all of them. The deinit
hops to main before mutating @published state since AppKit may release
the provider off-main.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 74cfa42d68

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread desktop/Desktop/Sources/MainWindow/Pages/TasksPage.swift
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 5, 2026

Greptile Summary

This PR addresses three independent drag-to-reorder bugs in the macOS Tasks page, all within TasksPage.swift. The fixes are well-scoped and the PR description gives a thorough explanation of root causes and the rejected alternatives (e.g. NSEvent monitors inside the AppKit drag loop).

  • Handle visibility: .onHover moved from taskRowContent to the outer HStack body so hovering over the handle area (a sibling of the content) correctly flips isHovering and reveals the glyph.
  • Persistence under filters: moveTask now applies sort-order writes to all three backing arrays (store.incompleteTasks, filteredFromDatabase, searchResults) and uses the existing suppressDatabaseRequery flag with a defer-based reset in syncSortOrders, preventing the debounced SQLite requery from overwriting the optimistic in-memory update.
  • Dim-while-dragging + end-of-drag cleanup: onDragStarted/onDragEnded promoted to non-optional (no-op defaults), wired end-to-end from TasksPageTaskCategorySectionTaskRow; drag-end signaled via TaskDragItemProvider.deinit which covers the dead-space, off-window, and escape-cancel paths that NSEvent monitors missed.

Confidence Score: 4/5

Safe to merge; all three fixes are additive and defensive against well-described existing bugs with no behavioral regressions on callers that don't supply the new callbacks.

The changes are carefully contained to the Tasks drag-reorder path. The suppressDatabaseRequery flag lifecycle is correct given the 300ms debounce on store.objectWillChange (the flag is always set synchronously before the debounce fires). The only non-trivial design note is that onDragEnded is now called twice on a successful drop — once by TaskDragDropModifier and once by TaskDragItemProvider.deinit — which is harmless in every realistic scenario but is an unintentional property of the architecture.

No files require special attention beyond the noted double-callback path in TasksPage.swift.

Important Files Changed

Filename Overview
desktop/Desktop/Sources/MainWindow/Pages/TasksPage.swift Three independent drag-to-reorder bugs fixed: .onHover relocated to outer HStack so handle is part of the hit region; moveTask now applies sort-order writes to all three backing arrays and uses suppressDatabaseRequery to block stale SQLite rewrites during the debounce window; TaskDragItemProvider subclass uses deinit to fire a reliable end-of-drag callback covering all drop paths. Minor: onDragEnded is called twice on a successful drop (once by TaskDragDropModifier, once by provider deinit), which is idempotent for the common case but creates a theoretical race for rapid re-drags.
desktop/CHANGELOG.json Adds one unreleased entry for the dim-while-dragging fix; the handle-visibility and persistence fixes are not mentioned.

Sequence Diagram

sequenceDiagram
    participant U as User
    participant TR as TaskRow (.onDrag)
    participant TDI as TaskDragItemProvider
    participant VM as TasksViewModel
    participant TDD as TaskDragDropModifier (.onDrop)
    participant SQL as SQLite (syncSortOrders)

    U->>TR: Grab drag handle
    TR->>TDI: init(taskId, onEnd: onDragEnded)
    TR-->>VM: DispatchQueue.main.async onDragStarted(id)
    VM->>VM: draggedTaskId = taskId
    VM-->>TR: isBeingDragged = true, opacity 0.4

    U->>TDD: Drop on target row
    TDD->>VM: onDragEnded() clears draggedTaskId
    TDD->>VM: onMoveTask calls moveTask()
    VM->>VM: applyOrder to all three backing arrays
    VM->>VM: suppressDatabaseRequery = true
    VM->>VM: recomputeDisplayCaches()
    VM->>VM: scheduleSortOrderSync() 500ms debounce

    Note over TDI: AppKit releases provider after loadItem completes
    TDI->>TDI: deinit fires
    TDI-->>VM: DispatchQueue.main.async onDragEnded() second call no-op

    Note over VM,SQL: 500ms later
    VM->>SQL: syncSortOrders() writes new sortOrders
    SQL-->>VM: defer suppressDatabaseRequery = false
Loading

Comments Outside Diff (1)

  1. desktop/Desktop/Sources/MainWindow/Pages/TasksPage.swift, line 3625 (link)

    P2 Double onDragEnded dispatch on successful drops

    onDragEnded?() fires here synchronously during the drop handler, clearing draggedTaskId. Separately, TaskDragItemProvider.deinit will also dispatch a second onDragEnded() call to the main queue once the provider is released (after loadItem completes). For the normal case both calls set draggedTaskId = nil, so the outcome is identical and harmless.

    The subtle risk is if a new drag starts between these two calls: the delayed deinit callback would clear the new drag's draggedTaskId prematurely, removing the dim on an unrelated row. The window is sub-millisecond (in-process loadItem + one main.async hop), making this essentially impossible to trigger with human interaction — but the two-call path is an unintentional property of the design worth noting. A guard like if draggedTaskId == nil { return } inside onDragEnded would make it idempotent against this scenario.

Reviews (1): Last reviewed commit: "feat(desktop/tasks): wire drag-row dim w..." | Re-trigger Greptile

Comment thread desktop/CHANGELOG.json
eulicesl and others added 3 commits May 5, 2026 19:19
Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
syncSortOrders() now calls recomputeAllCaches() after clearing
suppressDatabaseRequery=false, matching the pattern used by
clearTodayDeadlinesForIncompleteTasks(). This ensures filteredFromDatabase
is refreshed when non-status filters are active, preventing stale data
during the debounce window.
… NSItemProvider deinit

`TaskDragItemProvider.deinit` fires `onDragEnded` after AppKit releases
the provider — typically right after the synchronous drop handler in
`TaskDragDropModifier.onDrop` has already called `onDragEnded` itself.
The two calls land on the same closure that clears `draggedTaskId` and
`dropTargetTaskId`, so the duplicate is a no-op in the common case.

The subtle risk Greptile flagged in the review of BasedHardware#7185 is the
cross-drag race: if a brand-new drag starts in the sub-ms window
between the synchronous drop and the deinit-queued main.async hop, the
deferred call would clear the new drag's `draggedTaskId` prematurely.
The window is unreachable by human interaction (one main.async tick
plus user mouse movement), but the two-call path was an unintentional
property of the design rather than an explicit guarantee.

Add `guard viewModel.draggedTaskId != nil else { return }` so the
closure is strictly idempotent. Comment makes the intent explicit so
future cleanups don't strip the guard as redundant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@eulicesl
Copy link
Copy Markdown
Author

eulicesl commented May 6, 2026

@mdmohsin7 — would you mind taking a look when you have a moment? All three bot review threads are now resolved:

  • Codex P2 (stale filteredFromDatabase after suppressDatabaseRequery clears) — fixed in 2da0f8671 by calling recomputeAllCaches() after the flag reset, so the filtered list refreshes once SQLite is fresh.
  • Greptile P2 (CHANGELOG only mentioned the dim fix, omitted the other two user-visible bugs) — fixed in d19b1f0b9 by accepting Greptile's exact unreleased suggestion (three entries, one per fix).
  • Greptile P2 (double onDragEnded dispatch from TaskDragItemProvider.deinit) — fixed in f50472279 with an idempotency guard so the closure is strictly no-op on duplicate calls. Greptile's confidence on this one was 4/5 "safe to merge", but the guard removes the theoretical cross-drag race.

CI green (Lint & Format ✅), mergeable=MERGEABLE. No rush — happy to address any other feedback. cc @kodjima33 from the original cc.

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.

1 participant