Skip to content

fix(ui): stabilize agent note navigation#251

Open
kevinswiber wants to merge 2 commits intomodem-dev:mainfrom
kevinswiber:fix/agent-note-navigation-crash
Open

fix(ui): stabilize agent note navigation#251
kevinswiber wants to merge 2 commits intomodem-dev:mainfrom
kevinswiber:fix/agent-note-navigation-crash

Conversation

@kevinswiber
Copy link
Copy Markdown

@kevinswiber kevinswiber commented May 8, 2026

Summary

This is a combined fix for the agent-note navigation failures I hit while reviewing a live --agent-context session:

  • keep agent-note geometry stable so total review height no longer changes as note-bearing files enter/leave the viewport
  • resolve { / } and hunk session navigate --next-comment / --prev-comment relative to the full review stream when the current hunk is not itself annotated
  • match annotation ranges against the full visible hunk span, including context rows, so saved agent-context anchors inside a hunk remain navigable
  • keep comment navigation non-cyclic at annotated-stream edges, matching normal hunk-navigation clamp behavior

Fixes #234.
Fixes #235.
Refs #233.

Relationship to the other open PRs

There are three related open PRs. This PR overlaps with them, but it is not identical.

What is worth keeping if the other PRs land first

If #242, #243, and #244 land before this PR, I can rebase and narrow this branch. The part that still appears worth merging from this PR is:

  • stream-relative annotated navigation in src/ui/lib/hunks.ts
  • the React/session callsites that pass the full hunk stream into that helper (useReviewController.ts, reviewState.ts)
  • the unit regression in src/ui/lib/hunks.test.ts
  • the live PTY regression for repeated } navigation through an agent-context sidecar

Those pieces are not reflected in #242, #243, or #244 as currently written.

Optional pieces after a rebase:

Repro Steps

Bottom-edge snap-back with notes (#234)

  1. Open a multi-file review: hunk diff <range>.
  2. Attach agent notes to a file that can scroll off the top, for example:
    printf '%s\n' '{"comments":[{"filePath":"<early-file>","hunkNumber":1,"summary":"note"}]}' \
      | hunk session comment apply --repo <repo> --stdin
  3. Scroll to the last line of the last file.
  4. Try to scroll one line farther.

Before this change, the viewport can snap upward by roughly the height of off-top note rows because total content height changes with the viewport.

Next/previous comment from a non-annotated hunk (#235)

  1. Open a multi-file review and attach notes to several hunks.
  2. Press } a couple of times to move through notes.
  3. Scroll or press ] so the selected/centered hunk is not one of the annotated hunks.
  4. Press } again.

Before this change, the fallback path in findNextHunkCursor treats the current hunk as absent from the filtered annotated cursor list and jumps to the first annotated hunk instead of the next annotated hunk after the current stream position.

Context-row anchors skipped by comment navigation

  1. Use a hunk with leading context before the changed line.
  2. Add a hunk-level comment so Hunk resolves the note anchor to the first changed row inside the visible hunk span.
  3. Navigate with } or hunk session navigate --next-comment.

Before this change, hunkLineRange used additionLines / deletionLines, which count changed lines only, not the full hunk header extent. Anchors on context-adjacent rows could then fall outside the hunk range and never enter the annotated cursor list.

Fix

Stable agent-note geometry

DiffPane now derives sectionGeometry from the full per-file note map rather than a viewport-restricted note map. That keeps totalContentHeight independent of scrollViewport.top, so bottom-edge clamping has a stable ceiling while scrolling.

Stream-relative annotated navigation

findNextHunkCursor now accepts the full stream cursor list. If the current hunk is absent from the target subset, annotated navigation finds the nearest target cursor before/after the current stream position instead of falling back to the first/last annotated cursor.

When the current stream position is outside the annotated span, comment navigation clamps to the nearest annotated edge instead of wrapping. That matches normal hunk navigation and is now documented/tested explicitly.

Both the React review controller and session command path pass the full stream into that helper.

Full hunk-span range matching

hunkLineRange now uses Pierre's required header span counts (additionCount / deletionCount). agentAnnotations uses that shared helper, so live comments, sidecar notes, and annotated-hunk discovery all agree on hunk extent.

Review follow-up

After Greptile review, this branch drops the defensive keyboard callback refs rather than expanding that pattern. OpenTUI's current useKeyboard already keeps the latest handler via useEffectEvent, and the refs were not part of the root fix.

Tradeoffs

Testing

Passed on current branch head:

bun test src/core/liveComments.test.ts src/ui/lib/hunks.test.ts src/ui/components/ui-components.test.tsx src/ui/AppHost.interactions.test.tsx -t "line numbers|hunk ranges|overlapping annotations|uses full stream position|manual scrolling move away|comment"
tmp=$(mktemp -d); XDG_CONFIG_HOME="$tmp" bun test test/pty/ui-integration.test.ts -t "comment navigation between agent-context notes"; rc=$?; rm -rf "$tmp"; exit $rc
bun run lint
bun run format:check

bun run typecheck currently fails on main itself, independent of this branch:

src/ui/AppHost.interactions.test.tsx(267,10): error TS2552: Cannot find name 'createTestGitAppBootstrap'. Did you mean 'createTestVcsAppBootstrap'?

I left that unrelated base issue out of this PR. I can rebase once main is fixed.

Keep agent-note row geometry stable across viewport changes, resolve comment navigation relative to the full review stream, and keep keyboard navigation callbacks current across selection changes.

Use full Pierre hunk spans when matching annotation ranges so saved agent-context anchors inside visible hunk rows remain navigable.

Refs modem-dev#233.

Refs modem-dev#234.

Refs modem-dev#235.
@greptile-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 8, 2026

Greptile Summary

This PR addresses three related agent-note navigation failures: scroll-height instability caused by a viewport-gated note-measurement path, incorrect comment navigation from non-annotated hunks, and annotation anchors falling outside hunk ranges when context rows were excluded.

  • Stable geometry: DiffPane now derives sectionGeometry from the full per-file note map (allAgentNotesByFile) rather than a viewport-restricted subset, removing the height oscillation that could cause bottom-edge snap-back. Both geometry and rendered sections share the same note set, with an acknowledged per-render cost for files that have many notes.
  • Stream-relative annotated navigation: findNextHunkCursor accepts an optional full streamCursors list; when the current hunk is absent from the annotated subset it uses stream position to find the nearest annotated cursor forward or backward, instead of defaulting to first/last. Both the React controller and the session command path pass this list.
  • Full hunk-span range matching: hunkLineRange now uses additionCount/deletionCount (the complete hunk-header span, including context rows) with a fallback to the old changed-line counts; the same helper is shared via import rather than duplicated in agentAnnotations.ts.

Confidence Score: 4/5

Safe to merge; all three bug fixes are well-targeted, covered by new unit and PTY integration tests, and the one acknowledged tradeoff (all-file note geometry computation) is intentional and bounded by existing windowing.

The changes are correctness-oriented with good test coverage. The only non-trivial concerns are the partial ref-ization of keyboard callbacks (two callbacks protected, others left unguarded) and the silent fallback in hunkLineRange to the old narrower count if additionCount were ever absent — both are defensive gaps rather than active defects.

src/ui/lib/hunks.ts and src/ui/hooks/useAppKeyboardShortcuts.ts deserve a second look for the navigation boundary semantics and the ref-ization pattern respectively.

Important Files Changed

Filename Overview
src/ui/lib/hunks.ts Adds stream-relative fallback to findNextHunkCursor via a new findNearestCursorIndex helper; logic is correct and covered by tests.
src/core/liveComments.ts hunkLineRange now uses additionCount/deletionCount (full hunk span) with fallback to additionLines/deletionLines; the fallback arm is effectively dead code since additionCount is always defined in production.
src/ui/components/panes/DiffPane.tsx Removes viewport-restricted visibleAgentNotesByFile; both sectionGeometry and rendered sections now use allAgentNotesByFile, stabilising total content height. Intentional performance tradeoff acknowledged in PR description.
src/ui/hooks/useAppKeyboardShortcuts.ts Adds refs for moveToAnnotatedHunk and moveToHunk so callbacks stay fresh if OpenTUI changes how it stores them; other callbacks in the same hook remain un-ref-ized.
src/ui/hooks/useReviewController.ts Passes full hunkCursors as the stream context to findNextHunkCursor in moveToAnnotatedHunk; dependency array updated correctly.
src/ui/lib/reviewState.ts resolveReviewNavigationTarget now builds and passes a full hunkCursors stream to findNextHunkCursor for the session-command comment-navigation path.
src/ui/lib/agentAnnotations.ts Removes the local hunkLineRange duplicate in favour of the shared core import; no semantic change to annotation overlap logic.
test/pty/harness.ts Adds createAgentNavigationRepoFixture with a three-file fixture that exercises annotated comment navigation in the PTY integration tests.
test/pty/ui-integration.test.ts New PTY test verifies that pressing } twice navigates through agent-context notes without crashing or triggering React's maximum-update-depth error.
src/core/liveComments.test.ts Extends hunkLineRange tests to cover context-row lines and updates range expectations to reflect the wider additionCount/deletionCount span.
src/ui/lib/hunks.test.ts Adds a test case for stream-relative navigation from an unannotated current hunk covering forward, backward, and boundary cases.
src/ui/components/ui-components.test.tsx Extends the bottom-clamped scrolling test to include an off-screen file with agent notes, ensuring geometry stays stable with showAgentNotes enabled.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["User presses } or {"] --> B["useAppKeyboardShortcuts\nmoveToAnnotatedHunkRef.current"]
    B --> C["useReviewController\nmoveToAnnotatedHunk"]
    C --> D["findNextHunkCursor(annotatedCursors, delta, hunkCursors)"]
    D --> E{"current hunk\nin annotatedCursors?"}
    E -- Yes --> F["clamp currentIndex + delta\nto annotated list bounds"]
    E -- No --> G["findNearestCursorIndex\nusing full stream position"]
    G --> H{"currentFileId\nin streamCursors?"}
    H -- No --> I["fallback: first or last\nannotated cursor"]
    H -- Yes --> J["find nearest annotated cursor\nbefore/after stream position"]
    F --> K["selectHunk with scrollToNote=true"]
    J --> K
    I --> K
Loading
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 3
src/ui/hooks/useAppKeyboardShortcuts.ts:84-95
**Partial ref-ization of keyboard callbacks**

`moveToAnnotatedHunk` and `moveToHunk` are now dispatched through refs, but the other action callbacks captured by the same keyboard handler closure — `scrollDiff`, `scrollCodeHorizontally`, `cycleTheme`, `toggleAgentNotes`, etc. — are still read from the stale closure directly. If the stated motivation (guarding against OpenTUI changing how it stores callbacks) ever becomes real, these would be vulnerable by the same logic. Either apply the ref pattern consistently to all callbacks or document exactly which ones OpenTUI is known to capture stably so future readers understand the asymmetry.

### Issue 2 of 3
src/core/liveComments.ts:13-14
**Fallback to `additionLines`/`deletionLines` appears unreachable**

`hunk.additionCount` and `hunk.deletionCount` are used without a nullish fallback throughout the codebase (`codeColumns.ts`, `pierre.ts`, `useHighlightedDiff.ts`), which implies Pierre always populates them. The `?? hunk.additionLines` arm silently falls back to the old changed-line-only count, so if `additionCount` were ever absent the function would quietly return a narrower range rather than an error — the same bug this PR is fixing. A comment explaining when `additionCount` can be absent, or an assertion/log, would make the intent clearer.

### Issue 3 of 3
src/ui/lib/hunks.ts:81-83
**Forward `}` from beyond the last annotated hunk jumps backward, not to first**

When the current hunk is not in `annotatedCursors` and its stream position is past all annotated hunks, `indexedCursors.find(...)` returns `undefined` and the fallback resolves to the last annotated cursor — effectively a backward jump. The old code would have jumped to `cursors[0]` (first annotated hunk) in this case. The new behaviour is arguably more useful (nearest rather than wrap-to-start), and the test suite covers it, but it's a visible UX change worth calling out explicitly in a comment for future maintainers.

Reviews (1): Last reviewed commit: "fix(ui): stabilize agent note navigation" | Re-trigger Greptile

Comment thread src/ui/hooks/useAppKeyboardShortcuts.ts Outdated
Comment on lines 84 to 95
// Keep navigation shortcuts resilient if the keyboard hook changes how it stores callbacks.
const moveToAnnotatedHunkRef = useRef(moveToAnnotatedHunk);
const moveToHunkRef = useRef(moveToHunk);
const pagerModeRef = useRef(pagerMode);
const showHelpRef = useRef(showHelp);

activeMenuIdRef.current = activeMenuId;
focusAreaRef.current = focusArea;
moveToAnnotatedHunkRef.current = moveToAnnotatedHunk;
moveToHunkRef.current = moveToHunk;
pagerModeRef.current = pagerMode;
showHelpRef.current = showHelp;
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 Partial ref-ization of keyboard callbacks

moveToAnnotatedHunk and moveToHunk are now dispatched through refs, but the other action callbacks captured by the same keyboard handler closure — scrollDiff, scrollCodeHorizontally, cycleTheme, toggleAgentNotes, etc. — are still read from the stale closure directly. If the stated motivation (guarding against OpenTUI changing how it stores callbacks) ever becomes real, these would be vulnerable by the same logic. Either apply the ref pattern consistently to all callbacks or document exactly which ones OpenTUI is known to capture stably so future readers understand the asymmetry.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/hooks/useAppKeyboardShortcuts.ts
Line: 84-95

Comment:
**Partial ref-ization of keyboard callbacks**

`moveToAnnotatedHunk` and `moveToHunk` are now dispatched through refs, but the other action callbacks captured by the same keyboard handler closure — `scrollDiff`, `scrollCodeHorizontally`, `cycleTheme`, `toggleAgentNotes`, etc. — are still read from the stale closure directly. If the stated motivation (guarding against OpenTUI changing how it stores callbacks) ever becomes real, these would be vulnerable by the same logic. Either apply the ref pattern consistently to all callbacks or document exactly which ones OpenTUI is known to capture stably so future readers understand the asymmetry.

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

Comment thread src/core/liveComments.ts Outdated
Comment on lines +13 to +14
const additionSpan = hunk.additionCount ?? hunk.additionLines;
const deletionSpan = hunk.deletionCount ?? hunk.deletionLines;
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 Fallback to additionLines/deletionLines appears unreachable

hunk.additionCount and hunk.deletionCount are used without a nullish fallback throughout the codebase (codeColumns.ts, pierre.ts, useHighlightedDiff.ts), which implies Pierre always populates them. The ?? hunk.additionLines arm silently falls back to the old changed-line-only count, so if additionCount were ever absent the function would quietly return a narrower range rather than an error — the same bug this PR is fixing. A comment explaining when additionCount can be absent, or an assertion/log, would make the intent clearer.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/core/liveComments.ts
Line: 13-14

Comment:
**Fallback to `additionLines`/`deletionLines` appears unreachable**

`hunk.additionCount` and `hunk.deletionCount` are used without a nullish fallback throughout the codebase (`codeColumns.ts`, `pierre.ts`, `useHighlightedDiff.ts`), which implies Pierre always populates them. The `?? hunk.additionLines` arm silently falls back to the old changed-line-only count, so if `additionCount` were ever absent the function would quietly return a narrower range rather than an error — the same bug this PR is fixing. A comment explaining when `additionCount` can be absent, or an assertion/log, would make the intent clearer.

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

Comment thread src/ui/lib/hunks.ts
Comment on lines +81 to +83
if (delta >= 0) {
const nextCursor = indexedCursors.find(({ streamIndex }) => streamIndex > currentStreamIndex);
return nextCursor?.index ?? indexedCursors[indexedCursors.length - 1]!.index;
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 Forward } from beyond the last annotated hunk jumps backward, not to first

When the current hunk is not in annotatedCursors and its stream position is past all annotated hunks, indexedCursors.find(...) returns undefined and the fallback resolves to the last annotated cursor — effectively a backward jump. The old code would have jumped to cursors[0] (first annotated hunk) in this case. The new behaviour is arguably more useful (nearest rather than wrap-to-start), and the test suite covers it, but it's a visible UX change worth calling out explicitly in a comment for future maintainers.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/ui/lib/hunks.ts
Line: 81-83

Comment:
**Forward `}` from beyond the last annotated hunk jumps backward, not to first**

When the current hunk is not in `annotatedCursors` and its stream position is past all annotated hunks, `indexedCursors.find(...)` returns `undefined` and the fallback resolves to the last annotated cursor — effectively a backward jump. The old code would have jumped to `cursors[0]` (first annotated hunk) in this case. The new behaviour is arguably more useful (nearest rather than wrap-to-start), and the test suite covers it, but it's a visible UX change worth calling out explicitly in a comment for future maintainers.

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant