diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index e8c7e5b5d..a285b9ad4 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -146,6 +146,9 @@ export function InboxSignalsTab() { (s) => s.toggleReportSelection, ); const selectRange = useInboxReportSelectionStore((s) => s.selectRange); + const selectExactRange = useInboxReportSelectionStore( + (s) => s.selectExactRange, + ); const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection); @@ -292,25 +295,49 @@ export function InboxSignalsTab() { } }, [focusListPane, showTwoPaneLayout]); + // Tracks the cursor position for keyboard navigation (the "moving end" of + // Shift+Arrow selection). Separated from `lastClickedId` which acts as the + // anchor so that the anchor stays fixed while the cursor extends the range. + const keyboardCursorIdRef = useRef(null); + const navigateReport = useCallback( - (direction: 1 | -1) => { + (direction: 1 | -1, shift: boolean) => { const list = reportsRef.current; if (list.length === 0) return; - // Find the current position based on the last selected report - const currentIds = selectedReportIdsRef.current; - const currentId = - currentIds.length > 0 ? currentIds[currentIds.length - 1] : null; - const currentIndex = currentId - ? list.findIndex((r) => r.id === currentId) + // Determine cursor position — the item to navigate away from + const cursorId = + keyboardCursorIdRef.current ?? + (selectedReportIdsRef.current.length > 0 + ? selectedReportIdsRef.current[ + selectedReportIdsRef.current.length - 1 + ] + : null); + const cursorIndex = cursorId + ? list.findIndex((r) => r.id === cursorId) : -1; const nextIndex = - currentIndex === -1 + cursorIndex === -1 ? 0 - : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); + : Math.max(0, Math.min(list.length - 1, cursorIndex + direction)); const nextId = list[nextIndex].id; - setSelectedReportIds([nextId]); + if (shift) { + // Anchor is the store's lastClickedId — the point where shift-selection started. + // selectExactRange replaces the selection with the exact range from anchor to cursor, + // so reversing direction correctly contracts the selection. + const anchor = + useInboxReportSelectionStore.getState().lastClickedId ?? nextId; + selectExactRange( + anchor, + nextId, + list.map((r) => r.id), + ); + keyboardCursorIdRef.current = nextId; + } else { + setSelectedReportIds([nextId]); + keyboardCursorIdRef.current = nextId; + } const container = leftPaneRef.current; const row = container?.querySelector( @@ -326,7 +353,7 @@ export function InboxSignalsTab() { row.style.scrollMarginTop = `${stickyHeaderHeight}px`; row.scrollIntoView({ block: "nearest" }); }, - [setSelectedReportIds], + [setSelectedReportIds, selectExactRange], ); // Window-level keyboard handler so arrow keys work regardless of which @@ -347,10 +374,10 @@ export function InboxSignalsTab() { if (e.key === "ArrowDown") { e.preventDefault(); - navigateReport(1); + navigateReport(1, e.shiftKey); } else if (e.key === "ArrowUp") { e.preventDefault(); - navigateReport(-1); + navigateReport(-1, e.shiftKey); } else if ( e.key === "Escape" && selectedReportIdsRef.current.length > 0 diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts index f5f9ec34a..08383d739 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts @@ -164,4 +164,78 @@ describe("inboxReportSelectionStore", () => { expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r3"); }); }); + + describe("selectExactRange", () => { + const orderedIds = ["r1", "r2", "r3", "r4", "r5"]; + + it("selects exactly the range from anchor to target", () => { + useInboxReportSelectionStore + .getState() + .selectExactRange("r2", "r4", orderedIds); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r2", "r3", "r4"], + ); + }); + + it("replaces existing selection instead of merging", () => { + useInboxReportSelectionStore.setState({ + selectedReportIds: ["r1", "r5"], + }); + + useInboxReportSelectionStore + .getState() + .selectExactRange("r2", "r4", orderedIds); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r2", "r3", "r4"], + ); + }); + + it("keeps lastClickedId as the anchor", () => { + useInboxReportSelectionStore + .getState() + .selectExactRange("r2", "r4", orderedIds); + + expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r2"); + }); + + it("contracts selection when cursor moves back toward anchor", () => { + // Simulate: anchor=r2, extend to r4, then contract back to r3 + useInboxReportSelectionStore + .getState() + .selectExactRange("r2", "r4", orderedIds); + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r2", "r3", "r4"], + ); + + useInboxReportSelectionStore + .getState() + .selectExactRange("r2", "r3", orderedIds); + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r2", "r3"], + ); + }); + + it("works in reverse direction", () => { + useInboxReportSelectionStore + .getState() + .selectExactRange("r4", "r2", orderedIds); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r2", "r3", "r4"], + ); + expect(useInboxReportSelectionStore.getState().lastClickedId).toBe("r4"); + }); + + it("selects just the target when anchor is not in the ordered list", () => { + useInboxReportSelectionStore + .getState() + .selectExactRange("r99", "r3", orderedIds); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + ["r3"], + ); + }); + }); }); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts index 812a38e67..d24c55404 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts @@ -14,6 +14,13 @@ interface InboxReportSelectionActions { /** Select a contiguous range from the last-clicked report to `toId` within the given ordered list. * Existing selection outside the range is preserved (shift-click behavior). */ selectRange: (toId: string, orderedIds: string[]) => void; + /** Select exactly the contiguous range from `anchorId` to `toId`, replacing the entire selection. + * Unlike `selectRange`, this does not merge with existing selection — used for Shift+Arrow keyboard navigation. */ + selectExactRange: ( + anchorId: string, + toId: string, + orderedIds: string[], + ) => void; isReportSelected: (reportId: string) => boolean; clearSelection: () => void; pruneSelection: (visibleReportIds: string[]) => void; @@ -67,6 +74,20 @@ export const useInboxReportSelectionStore = create()( return { selectedReportIds: merged, lastClickedId: toId }; }), + selectExactRange: (anchorId, toId, orderedIds) => + set(() => { + const anchorIndex = orderedIds.indexOf(anchorId); + const toIndex = orderedIds.indexOf(toId); + if (anchorIndex === -1 || toIndex === -1) { + return { selectedReportIds: [toId], lastClickedId: toId }; + } + const start = Math.min(anchorIndex, toIndex); + const end = Math.max(anchorIndex, toIndex); + const rangeIds = orderedIds.slice(start, end + 1); + // Keep lastClickedId as the anchor — the caller manages cursor position + return { selectedReportIds: rangeIds, lastClickedId: anchorId }; + }), + isReportSelected: (reportId) => get().selectedReportIds.includes(reportId), clearSelection: () => set({ selectedReportIds: [], lastClickedId: null }),