Skip to content

fix(ui): only show add-note affordance after pointer movement#331

Merged
benvinegar merged 1 commit into
mainfrom
fix/add-note-pointer-move-only
May 19, 2026
Merged

fix(ui): only show add-note affordance after pointer movement#331
benvinegar merged 1 commit into
mainfrom
fix/add-note-pointer-move-only

Conversation

@benvinegar
Copy link
Copy Markdown
Member

Summary

  • clear the hover-only [+] affordance whenever review content scrolls under the pointer
  • stop row onMouseOver from activating [+]; only onMouseMove now activates it
  • keep [+] hidden after wheel, keyboard, or programmatic scroll until the pointer actually moves again

Tests

  • bun run typecheck
  • bun test src/ui/components/ui-components.test.tsx
  • bun test src/ui/components/ui-components.test.tsx -t "DiffPane only shows"
  • bun run lint

@benvinegar benvinegar force-pushed the fix/add-note-pointer-move-only branch from 47694bc to a80ac1a Compare May 19, 2026 13:33
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 19, 2026

Greptile Summary

Replaces the timeout-based hover-suppression mechanism with a signal counter (hoverClearSignal) that immediately clears the [+] add-note affordance whenever content scrolls under the pointer (wheel, keyboard, or programmatic). The outer <box> no longer listens to onMouseOver; only onMouseMove inside DiffRowView can activate the affordance, so the badge stays hidden until the user physically moves the cursor after a scroll.

  • Removes the 160 ms suppress/resume timeout in DiffPane and replaces it with an incrementing addNoteHoverClearSignal that propagates via props down to PierreDiffView, where a useEffect reacts to changes and calls clearHoveredRow().
  • Scroll detection is now hooked into both the wheel handler and the viewport-change observer, so keyboard and programmatic scrolls also clear the affordance.
  • Tests are extended to verify the badge stays hidden across a 250 ms wait after a wheel scroll and reappears only after a mouse-move, and that a programmatic scrollTo also hides it.

Confidence Score: 4/5

The change is safe to merge. The core fix—switching from onMouseOver to onMouseMove and clearing state via a counter signal rather than a timed suppress/resume—is correctly implemented and well-tested.

The signal-counter approach cleanly replaces the old timeout mechanism. The one rough edge is in the idle-timeout callback inside activateHoveredRow: onActiveAddNoteAffordanceChange?.(null) is called regardless of whether the state updater actually changed hoveredRowKey, creating a subtle asymmetry. It works today because clearHoverIdleTimeout is always invoked before hoveredRowKey can change, but the assumption is implicit. The test coverage for the new behavior is solid.

src/ui/diff/PierreDiffView.tsx — the idle-timeout callback has an unconditional onActiveAddNoteAffordanceChange call that is worth tightening up.

Important Files Changed

Filename Overview
src/ui/diff/PierreDiffView.tsx Core of the fix: replaces hoverSuppressed bool + lastHoveredRowKeyRef tracking with a hoverClearSignal counter; removes onMouseOver from the diff-row box; has a subtle unconditional onActiveAddNoteAffordanceChange?.(null) call in the idle timeout that is safe due to invariants but asymmetric with the guarded setHoveredRowKey updater.
src/ui/components/panes/DiffPane.tsx Replaces timeout-based suppression with clearAddNoteHoverForScroll callback; correctly wired into both the wheel handler and the viewport-change scroll observer; clearAddNoteHoverForScroll added to the viewport-observer useEffect dependency array.
src/ui/components/panes/DiffSection.tsx Mechanical prop rename from hoverSuppressed (bool) to hoverClearSignal (number); memo comparator updated correctly.
src/ui/components/ui-components.test.tsx Test significantly strengthened: now verifies the badge stays hidden after 250 ms (no timeout-based re-enable), reappears only after an explicit mouse move, and disappears again on programmatic scroll.

Sequence Diagram

sequenceDiagram
    participant User
    participant DiffPane
    participant DiffSection
    participant PierreDiffView
    participant DiffRowView

    Note over User,DiffRowView: Scroll clears affordance
    User->>DiffPane: wheel / keyboard / programmatic scroll
    DiffPane->>DiffPane: "clearAddNoteHoverForScroll()<br/>setAddNoteHoverClearSignal(n+1)<br/>onActiveAddNoteAffordanceChange(null)"
    DiffPane->>DiffSection: "hoverClearSignal=n+1"
    DiffSection->>PierreDiffView: "hoverClearSignal=n+1"
    PierreDiffView->>PierreDiffView: "useEffect detects signal change<br/>clearHoveredRow() / setHoveredRowKey(null)"

    Note over User,DiffRowView: Only pointer movement re-activates
    User->>DiffRowView: onMouseMove
    DiffRowView->>PierreDiffView: onHoverRow()
    PierreDiffView->>PierreDiffView: "activateHoveredRow(rowKey)<br/>setHoveredRowKey(rowKey) / schedule idle timer"
    PierreDiffView->>DiffPane: onActiveAddNoteAffordanceChange(affordance)
    DiffRowView->>DiffRowView: "showAddNoteBadge=true renders [+]"

    Note over User,DiffRowView: Idle timeout hides affordance
    PierreDiffView->>PierreDiffView: "idle timer fires<br/>setHoveredRowKey(null) if current===rowKey<br/>onActiveAddNoteAffordanceChange(null)"
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
src/ui/diff/PierreDiffView.tsx:119-123
The idle-timeout callback calls `onActiveAddNoteAffordanceChange?.(null)` unconditionally, but only clears `hoveredRowKey` when `current === rowKey`. If somehow `current !== rowKey` when the timer fires, the parent's affordance state would be cleared while the child still has a different row highlighted. The invariant that `clearHoverIdleTimeout` is always called before `hoveredRowKey` changes makes this safe today, but the asymmetry is easy to misread. Guarding the callback with the same condition makes the code self-documenting and future-proof.

```suggestion
      hoverIdleTimeoutRef.current = setTimeout(() => {
        setHoveredRowKey((current) => {
          if (current === rowKey) {
            onActiveAddNoteAffordanceChange?.(null);
            return null;
          }
          return current;
        });
        hoverIdleTimeoutRef.current = null;
      }, ADD_NOTE_IDLE_HIDE_DELAY_MS);
```

Reviews (1): Last reviewed commit: "fix(ui): only show add-note affordance a..." | Re-trigger Greptile

Comment on lines 119 to 123
hoverIdleTimeoutRef.current = setTimeout(() => {
if (hoverIdleDeadlineRef.current !== deadline) {
return;
}

if (clearCurrentRow || lastHoveredRowKeyRef.current === rowKey) {
lastHoveredRowKeyRef.current = null;
setHoveredRowKey(null);
onActiveAddNoteAffordanceChange?.(null);
}
hoverIdleDeadlineRef.current = null;
setHoveredRowKey((current) => (current === rowKey ? null : current));
onActiveAddNoteAffordanceChange?.(null);
hoverIdleTimeoutRef.current = null;
}, ADD_NOTE_IDLE_HIDE_DELAY_MS);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 The idle-timeout callback calls onActiveAddNoteAffordanceChange?.(null) unconditionally, but only clears hoveredRowKey when current === rowKey. If somehow current !== rowKey when the timer fires, the parent's affordance state would be cleared while the child still has a different row highlighted. The invariant that clearHoverIdleTimeout is always called before hoveredRowKey changes makes this safe today, but the asymmetry is easy to misread. Guarding the callback with the same condition makes the code self-documenting and future-proof.

Suggested change
hoverIdleTimeoutRef.current = setTimeout(() => {
if (hoverIdleDeadlineRef.current !== deadline) {
return;
}
if (clearCurrentRow || lastHoveredRowKeyRef.current === rowKey) {
lastHoveredRowKeyRef.current = null;
setHoveredRowKey(null);
onActiveAddNoteAffordanceChange?.(null);
}
hoverIdleDeadlineRef.current = null;
setHoveredRowKey((current) => (current === rowKey ? null : current));
onActiveAddNoteAffordanceChange?.(null);
hoverIdleTimeoutRef.current = null;
}, ADD_NOTE_IDLE_HIDE_DELAY_MS);
hoverIdleTimeoutRef.current = setTimeout(() => {
setHoveredRowKey((current) => {
if (current === rowKey) {
onActiveAddNoteAffordanceChange?.(null);
return null;
}
return current;
});
hoverIdleTimeoutRef.current = null;
}, ADD_NOTE_IDLE_HIDE_DELAY_MS);
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/diff/PierreDiffView.tsx
Line: 119-123

Comment:
The idle-timeout callback calls `onActiveAddNoteAffordanceChange?.(null)` unconditionally, but only clears `hoveredRowKey` when `current === rowKey`. If somehow `current !== rowKey` when the timer fires, the parent's affordance state would be cleared while the child still has a different row highlighted. The invariant that `clearHoverIdleTimeout` is always called before `hoveredRowKey` changes makes this safe today, but the asymmetry is easy to misread. Guarding the callback with the same condition makes the code self-documenting and future-proof.

```suggestion
      hoverIdleTimeoutRef.current = setTimeout(() => {
        setHoveredRowKey((current) => {
          if (current === rowKey) {
            onActiveAddNoteAffordanceChange?.(null);
            return null;
          }
          return current;
        });
        hoverIdleTimeoutRef.current = null;
      }, ADD_NOTE_IDLE_HIDE_DELAY_MS);
```

How can I resolve this? If you propose a fix, please make it concise.

@benvinegar benvinegar merged commit 11b58ca into main May 19, 2026
5 checks passed
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