fix(inbox): re-seat keyboard cursor on click#2308
Conversation
Arrow-key navigation in the inbox kept a local `keyboardCursorIdRef` that was only written by the keyboard handler. Clicking a report changed the store selection but left the ref pointing at the previous keyboard position, so the next arrow press navigated from there instead of from the just-clicked report. Extracted the navigation into `useInboxKeyboardNavigation`, which subscribes to `lastClickedId` in the selection store and re-seats the cursor whenever a click moves it. Shift+Arrow still walks the cursor independently (it deliberately doesn't touch `lastClickedId`, which stays the anchor for range extension). Added 16 regression tests covering arrow-after-click for plain, cmd, shift clicks; shift+arrow range extension and contraction; bounds; and remount. Generated-By: PostHog Code Task-Id: 875c2ff4-ae68-49c8-9aed-46a832256901
Prompt To Fix All With AIFix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
apps/code/src/renderer/features/inbox/hooks/useInboxKeyboardNavigation.test.tsx:64-86
The two "empty selection" tests exercise the same body with `1` and `-1`; prefer `it.each` per the team's parameterised-tests rule.
```suggestion
it.each<[1 | -1, string]>([
[1, "ArrowDown"],
[-1, "ArrowUp"],
])("%s selects the first report when nothing is selected", (direction) => {
const { result } = renderHook(() =>
useInboxKeyboardNavigation({ reports: REPORTS }),
);
act(() => {
result.current.navigateReport(direction, false);
});
expect(getSelection()).toEqual(["a"]);
});
```
### Issue 2 of 2
apps/code/src/renderer/features/inbox/hooks/useInboxKeyboardNavigation.test.tsx:219-245
Same pattern in the bounds block — `ArrowDown at last` and `ArrowUp at first` are mirror images that collapse naturally into a single `it.each`.
```suggestion
it.each<[1 | -1, string]>([
[1, "e"],
[-1, "a"],
])("direction %i at the boundary stays on the same report", (direction, reportId) => {
const { result } = renderHook(() =>
useInboxKeyboardNavigation({ reports: REPORTS }),
);
plainClick(reportId);
act(() => {
result.current.navigateReport(direction, false);
});
expect(getSelection()).toEqual([reportId]);
});
```
Reviews (1): Last reviewed commit: "fix(inbox): re-seat keyboard cursor on c..." | Re-trigger Greptile |
| it("ArrowDown selects the first report", () => { | ||
| const { result } = renderHook(() => | ||
| useInboxKeyboardNavigation({ reports: REPORTS }), | ||
| ); | ||
|
|
||
| act(() => { | ||
| result.current.navigateReport(1, false); | ||
| }); | ||
|
|
||
| expect(getSelection()).toEqual(["a"]); | ||
| }); | ||
|
|
||
| it("ArrowUp also selects the first report when nothing is selected", () => { | ||
| const { result } = renderHook(() => | ||
| useInboxKeyboardNavigation({ reports: REPORTS }), | ||
| ); | ||
|
|
||
| act(() => { | ||
| result.current.navigateReport(-1, false); | ||
| }); | ||
|
|
||
| expect(getSelection()).toEqual(["a"]); | ||
| }); |
There was a problem hiding this comment.
The two "empty selection" tests exercise the same body with
1 and -1; prefer it.each per the team's parameterised-tests rule.
| it("ArrowDown selects the first report", () => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| act(() => { | |
| result.current.navigateReport(1, false); | |
| }); | |
| expect(getSelection()).toEqual(["a"]); | |
| }); | |
| it("ArrowUp also selects the first report when nothing is selected", () => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| act(() => { | |
| result.current.navigateReport(-1, false); | |
| }); | |
| expect(getSelection()).toEqual(["a"]); | |
| }); | |
| it.each<[1 | -1, string]>([ | |
| [1, "ArrowDown"], | |
| [-1, "ArrowUp"], | |
| ])("%s selects the first report when nothing is selected", (direction) => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| act(() => { | |
| result.current.navigateReport(direction, false); | |
| }); | |
| expect(getSelection()).toEqual(["a"]); | |
| }); |
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/inbox/hooks/useInboxKeyboardNavigation.test.tsx
Line: 64-86
Comment:
The two "empty selection" tests exercise the same body with `1` and `-1`; prefer `it.each` per the team's parameterised-tests rule.
```suggestion
it.each<[1 | -1, string]>([
[1, "ArrowDown"],
[-1, "ArrowUp"],
])("%s selects the first report when nothing is selected", (direction) => {
const { result } = renderHook(() =>
useInboxKeyboardNavigation({ reports: REPORTS }),
);
act(() => {
result.current.navigateReport(direction, false);
});
expect(getSelection()).toEqual(["a"]);
});
```
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| it("ArrowDown on the last report stays on the last report", () => { | ||
| const { result } = renderHook(() => | ||
| useInboxKeyboardNavigation({ reports: REPORTS }), | ||
| ); | ||
|
|
||
| plainClick("e"); | ||
|
|
||
| act(() => { | ||
| result.current.navigateReport(1, false); | ||
| }); | ||
|
|
||
| expect(getSelection()).toEqual(["e"]); | ||
| }); | ||
|
|
||
| it("ArrowUp on the first report stays on the first report", () => { | ||
| const { result } = renderHook(() => | ||
| useInboxKeyboardNavigation({ reports: REPORTS }), | ||
| ); | ||
|
|
||
| plainClick("a"); | ||
|
|
||
| act(() => { | ||
| result.current.navigateReport(-1, false); | ||
| }); | ||
|
|
||
| expect(getSelection()).toEqual(["a"]); | ||
| }); |
There was a problem hiding this comment.
Same pattern in the bounds block —
ArrowDown at last and ArrowUp at first are mirror images that collapse naturally into a single it.each.
| it("ArrowDown on the last report stays on the last report", () => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| plainClick("e"); | |
| act(() => { | |
| result.current.navigateReport(1, false); | |
| }); | |
| expect(getSelection()).toEqual(["e"]); | |
| }); | |
| it("ArrowUp on the first report stays on the first report", () => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| plainClick("a"); | |
| act(() => { | |
| result.current.navigateReport(-1, false); | |
| }); | |
| expect(getSelection()).toEqual(["a"]); | |
| }); | |
| it.each<[1 | -1, string]>([ | |
| [1, "e"], | |
| [-1, "a"], | |
| ])("direction %i at the boundary stays on the same report", (direction, reportId) => { | |
| const { result } = renderHook(() => | |
| useInboxKeyboardNavigation({ reports: REPORTS }), | |
| ); | |
| plainClick(reportId); | |
| act(() => { | |
| result.current.navigateReport(direction, false); | |
| }); | |
| expect(getSelection()).toEqual([reportId]); | |
| }); |
Context Used: Do not attempt to comment on incorrect alphabetica... (source)
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/code/src/renderer/features/inbox/hooks/useInboxKeyboardNavigation.test.tsx
Line: 219-245
Comment:
Same pattern in the bounds block — `ArrowDown at last` and `ArrowUp at first` are mirror images that collapse naturally into a single `it.each`.
```suggestion
it.each<[1 | -1, string]>([
[1, "e"],
[-1, "a"],
])("direction %i at the boundary stays on the same report", (direction, reportId) => {
const { result } = renderHook(() =>
useInboxKeyboardNavigation({ reports: REPORTS }),
);
plainClick(reportId);
act(() => {
result.current.navigateReport(direction, false);
});
expect(getSelection()).toEqual([reportId]);
});
```
**Context Used:** Do not attempt to comment on incorrect alphabetica... ([source](https://app.greptile.com/review/custom-context?memory=instruction-0))
How can I resolve this? If you propose a fix, please make it concise.Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Greptile flagged the ArrowDown/ArrowUp empty-selection pair and the boundary pair as identical apart from the direction. Combine each pair into a single `it.each`. Generated-By: PostHog Code Task-Id: 875c2ff4-ae68-49c8-9aed-46a832256901
Paul's suggestion in the thread was a component-level test that passes keyboard input and asserts on DOM state. The original PR went one level lower with `renderHook`, which exercises the hook contract but skips the wiring between window keydown events, click handlers, and the selection store. Adds a `TestInbox` harness that mirrors `InboxSignalsTab`'s window keydown handler and click dispatch, then drives it via `fireEvent.click` and `fireEvent.keyDown`. Covers the exact bug scenario (click → ArrowDown selects the report below the clicked one) plus shift/cmd-click and shift+arrow range behaviors. Also drops the contrived multi-instance test — it exercised the seeding logic rather than any user-visible behavior. Verified by temporarily disabling the cursor-sync effect: 16/22 tests fail, including all 6 new behavioral tests for the bug scenario. Generated-By: PostHog Code Task-Id: 875c2ff4-ae68-49c8-9aed-46a832256901
Summary
keyboardCursorIdRefthat was only written by the keyboard handler. After a click, the ref still pointed at the previous keyboard position, so the next arrow press navigated from there instead of from the just-clicked report.useInboxKeyboardNavigation, which subscribes tolastClickedIdin the selection store and re-seats the cursor on every click. Shift+Arrow still walks the cursor independently (it deliberately doesn't touchlastClickedId, which stays the anchor for range extension).Test plan
pnpm exec vitest run src/renderer/features/inbox/— 81/81 passpnpm --filter code typecheckpnpm exec biome checkon changed files