Skip to content

Commit 5a7e2ab

Browse files
committed
Rework inbox bulk selection UX
Made-with: Cursor
1 parent 41031df commit 5a7e2ab

File tree

5 files changed

+155
-12
lines changed

5 files changed

+155
-12
lines changed

apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx

Lines changed: 94 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReport
1111
import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore";
1212
import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore";
1313
import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore";
14+
import { getEffectiveBulkSelectionIds } from "@features/inbox/utils/bulkSelection";
1415
import {
1516
buildSignalReportListOrdering,
1617
buildStatusFilterParam,
@@ -110,11 +111,26 @@ export function InboxSignalsTab() {
110111
const selectedReportIds = useInboxReportSelectionStore(
111112
(s) => s.selectedReportIds ?? [],
112113
);
114+
const setSelectedReportIds = useInboxReportSelectionStore(
115+
(s) => s.setSelectedReportIds,
116+
);
113117
const toggleReportSelection = useInboxReportSelectionStore(
114118
(s) => s.toggleReportSelection,
115119
);
116120
const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection);
117121

122+
// When true, an empty store means "nothing selected" — no virtual fallback.
123+
// Set once the user first explicitly interacts with any checkbox.
124+
// Resets when the open report changes while the store is still empty (fresh report).
125+
const [selectionExplicitlyActivated, setSelectionExplicitlyActivated] =
126+
useState(false);
127+
128+
// Stable refs so callbacks don't need re-registration on every render
129+
const selectedReportIdsRef = useRef(selectedReportIds);
130+
selectedReportIdsRef.current = selectedReportIds;
131+
const selectionExplicitlyActivatedRef = useRef(false);
132+
selectionExplicitlyActivatedRef.current = selectionExplicitlyActivated;
133+
118134
useEffect(() => {
119135
if (reports.length === 0) {
120136
setSelectedReportId(null);
@@ -135,11 +151,79 @@ export function InboxSignalsTab() {
135151
pruneSelection(reports.map((report) => report.id));
136152
}, [reports, pruneSelection]);
137153

154+
// Reset to virtual mode when a different report is opened while the store is empty.
155+
// selectedReportIdsRef is read (not declared) in the callback — biome can't see it
156+
// depends on selectedReportId, so the dep is intentional.
157+
// biome-ignore lint/correctness/useExhaustiveDependencies: selectedReportId is the trigger; store length is read via a ref to avoid adding it as a dep
158+
useEffect(() => {
159+
if (selectedReportIdsRef.current.length === 0) {
160+
setSelectionExplicitlyActivated(false);
161+
}
162+
}, [selectedReportId]);
163+
138164
const selectedReport = useMemo(
139165
() => reports.find((report) => report.id === selectedReportId) ?? null,
140166
[reports, selectedReportId],
141167
);
142168

169+
const effectiveBulkIds = useMemo(
170+
() =>
171+
getEffectiveBulkSelectionIds(
172+
selectedReportIds,
173+
selectedReportId,
174+
selectionExplicitlyActivated,
175+
),
176+
[selectedReportIds, selectedReportId, selectionExplicitlyActivated],
177+
);
178+
179+
// Toggle a report's checkbox, handling the virtual → explicit mode transition.
180+
// When the first explicit toggle happens in virtual mode (store empty, a report is open):
181+
// - toggling a DIFFERENT report: seed the open report into the store too (keep it checked)
182+
// - toggling the OPEN report itself: transition to explicit-empty (uncheck it)
183+
const handleToggleReportSelection = useCallback(
184+
(reportId: string) => {
185+
if (
186+
!selectionExplicitlyActivatedRef.current &&
187+
selectedReportIdsRef.current.length === 0
188+
) {
189+
setSelectionExplicitlyActivated(true);
190+
if (
191+
selectedReportIdRef.current !== null &&
192+
reportId !== selectedReportIdRef.current
193+
) {
194+
// Seed the open report + add the newly toggled one
195+
setSelectedReportIds([selectedReportIdRef.current, reportId]);
196+
}
197+
// If toggling the open report's own checkbox, the store stays empty
198+
// and explicit = true → effective = [] (it becomes unchecked)
199+
} else {
200+
toggleReportSelection(reportId);
201+
}
202+
},
203+
[setSelectedReportIds, toggleReportSelection],
204+
);
205+
206+
// Handle the select-all checkbox. Parent owns all state transitions.
207+
const handleToggleSelectAll = useCallback(
208+
(checked: boolean) => {
209+
if (checked) {
210+
setSelectedReportIds(reportsRef.current.map((r) => r.id));
211+
setSelectionExplicitlyActivated(true);
212+
} else {
213+
setSelectedReportIds([]);
214+
if (!selectionExplicitlyActivatedRef.current) {
215+
// Was in virtual mode (open report only virtually selected):
216+
// close the report so there is truly nothing selected.
217+
setSelectedReportId(null);
218+
setSelectionExplicitlyActivated(false);
219+
}
220+
// If already in explicit mode, keep the flag true so the empty store
221+
// means nothing selected — no fallback to the virtual open report.
222+
}
223+
},
224+
[setSelectedReportIds],
225+
);
226+
143227
// ── Sidebar resize ─────────────────────────────────────────────────────
144228
const sidebarWidth = useInboxSignalsSidebarStore((state) => state.width);
145229
const sidebarIsResizing = useInboxSignalsSidebarStore(
@@ -279,14 +363,17 @@ export function InboxSignalsTab() {
279363
} else if (e.key === "ArrowUp") {
280364
e.preventDefault();
281365
navigateReport(-1);
282-
} else if (e.key === " " && selectedReportIdRef.current) {
366+
} else if (
367+
(e.key === " " || e.key === "Enter") &&
368+
selectedReportIdRef.current
369+
) {
283370
e.preventDefault();
284-
toggleReportSelection(selectedReportIdRef.current);
371+
handleToggleReportSelection(selectedReportIdRef.current);
285372
}
286373
};
287374
window.addEventListener("keydown", handler);
288375
return () => window.removeEventListener("keydown", handler);
289-
}, [navigateReport, toggleReportSelection]);
376+
}, [navigateReport, handleToggleReportSelection]);
290377

291378
const searchDisabledReason =
292379
!hasReports && !searchQuery.trim()
@@ -381,6 +468,8 @@ export function InboxSignalsTab() {
381468
readyCount={readyCount}
382469
processingCount={processingCount}
383470
reports={reports}
471+
effectiveBulkIds={effectiveBulkIds}
472+
onToggleSelectAll={handleToggleSelectAll}
384473
/>
385474
</Box>
386475
<ReportListPane
@@ -397,9 +486,9 @@ export function InboxSignalsTab() {
397486
searchQuery={searchQuery}
398487
hasActiveFilters={hasActiveFilters}
399488
selectedReportId={selectedReportId}
400-
selectedReportIds={selectedReportIds}
489+
selectedReportIds={effectiveBulkIds}
401490
onSelectReport={setSelectedReportId}
402-
onToggleReportSelection={toggleReportSelection}
491+
onToggleReportSelection={handleToggleReportSelection}
403492
/>
404493
</Flex>
405494
</ScrollArea>

apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ export function SignalsToolbar({
142142
snoozeSelected,
143143
deleteSelected,
144144
reingestSelected,
145-
} = useInboxBulkActions(reports);
145+
} = useInboxBulkActions(reports, effectiveBulkIds);
146146

147147
const countLabel = isSearchActive
148148
? `${filteredCount} of ${totalCount}`

apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,18 +135,18 @@ function getSelectedReportEligibility(
135135
};
136136
}
137137

138-
export function useInboxBulkActions(reports: SignalReport[]) {
138+
export function useInboxBulkActions(
139+
reports: SignalReport[],
140+
effectiveBulkIds: string[],
141+
) {
139142
const queryClient = useQueryClient();
140-
const selectedReportIds = useInboxReportSelectionStore(
141-
(state) => state.selectedReportIds ?? [],
142-
);
143143
const clearSelection = useInboxReportSelectionStore(
144144
(state) => state.clearSelection,
145145
);
146146

147147
const eligibility = useMemo(
148-
() => getSelectedReportEligibility(reports, selectedReportIds),
149-
[reports, selectedReportIds],
148+
() => getSelectedReportEligibility(reports, effectiveBulkIds),
149+
[reports, effectiveBulkIds],
150150
);
151151

152152
const invalidateInboxQueries = useCallback(async () => {
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { getEffectiveBulkSelectionIds } from "./bulkSelection";
3+
4+
describe("getEffectiveBulkSelectionIds", () => {
5+
describe("virtual mode (selectionExplicitlyActivated = false, default)", () => {
6+
it("uses store ids when non-empty", () => {
7+
expect(getEffectiveBulkSelectionIds(["a", "b"], "c")).toEqual(["a", "b"]);
8+
});
9+
10+
it("falls back to open report when store is empty", () => {
11+
expect(getEffectiveBulkSelectionIds([], "r1")).toEqual(["r1"]);
12+
});
13+
14+
it("returns empty when store is empty and nothing is open", () => {
15+
expect(getEffectiveBulkSelectionIds([], null)).toEqual([]);
16+
});
17+
});
18+
19+
describe("explicit mode (selectionExplicitlyActivated = true)", () => {
20+
it("returns store ids when non-empty", () => {
21+
expect(getEffectiveBulkSelectionIds(["a", "b"], "c", true)).toEqual([
22+
"a",
23+
"b",
24+
]);
25+
});
26+
27+
it("returns empty when store is empty, even with an open report", () => {
28+
expect(getEffectiveBulkSelectionIds([], "r1", true)).toEqual([]);
29+
});
30+
31+
it("returns empty when store is empty and nothing is open", () => {
32+
expect(getEffectiveBulkSelectionIds([], null, true)).toEqual([]);
33+
});
34+
});
35+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/**
2+
* Returns the effective set of report IDs for bulk actions and list checkboxes.
3+
*
4+
* Two modes:
5+
* - Virtual (selectionExplicitlyActivated = false, store empty): the currently open
6+
* report is treated as selected so bulk actions work without requiring a checkbox click.
7+
* - Explicit (selectionExplicitlyActivated = true OR store non-empty): the store is the
8+
* sole source of truth. An empty store means nothing is selected — no fallback.
9+
*/
10+
export function getEffectiveBulkSelectionIds(
11+
storeIds: string[],
12+
openReportId: string | null,
13+
selectionExplicitlyActivated = false,
14+
): string[] {
15+
if (selectionExplicitlyActivated || storeIds.length > 0) {
16+
return storeIds;
17+
}
18+
return openReportId != null ? [openReportId] : [];
19+
}

0 commit comments

Comments
 (0)