From 7f9bc818e7f4efd56d1a0c47060727713a8f198f Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 7 Apr 2026 20:05:54 +0200 Subject: [PATCH] Rework inbox bulk selection UX Made-with: Cursor --- .../inbox/components/InboxSignalsTab.tsx | 99 +++++++- .../inbox/components/list/SignalsToolbar.tsx | 233 +++++++++--------- .../inbox/hooks/useInboxBulkActions.ts | 12 +- .../inbox/utils/bulkSelection.test.ts | 35 +++ .../features/inbox/utils/bulkSelection.ts | 19 ++ 5 files changed, 264 insertions(+), 134 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/utils/bulkSelection.test.ts create mode 100644 apps/code/src/renderer/features/inbox/utils/bulkSelection.ts diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 36f364fac..466635a61 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -11,6 +11,7 @@ import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReport import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; +import { getEffectiveBulkSelectionIds } from "@features/inbox/utils/bulkSelection"; import { buildSignalReportListOrdering, buildStatusFilterParam, @@ -110,11 +111,26 @@ export function InboxSignalsTab() { const selectedReportIds = useInboxReportSelectionStore( (s) => s.selectedReportIds ?? [], ); + const setSelectedReportIds = useInboxReportSelectionStore( + (s) => s.setSelectedReportIds, + ); const toggleReportSelection = useInboxReportSelectionStore( (s) => s.toggleReportSelection, ); const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); + // When true, an empty store means "nothing selected" — no virtual fallback. + // Set once the user first explicitly interacts with any checkbox. + // Resets when the open report changes while the store is still empty (fresh report). + const [selectionExplicitlyActivated, setSelectionExplicitlyActivated] = + useState(false); + + // Stable refs so callbacks don't need re-registration on every render + const selectedReportIdsRef = useRef(selectedReportIds); + selectedReportIdsRef.current = selectedReportIds; + const selectionExplicitlyActivatedRef = useRef(false); + selectionExplicitlyActivatedRef.current = selectionExplicitlyActivated; + useEffect(() => { if (reports.length === 0) { setSelectedReportId(null); @@ -135,11 +151,79 @@ export function InboxSignalsTab() { pruneSelection(reports.map((report) => report.id)); }, [reports, pruneSelection]); + // Reset to virtual mode when a different report is opened while the store is empty. + // selectedReportIdsRef is read (not declared) in the callback — biome can't see it + // depends on selectedReportId, so the dep is intentional. + // biome-ignore lint/correctness/useExhaustiveDependencies: selectedReportId is the trigger; store length is read via a ref to avoid adding it as a dep + useEffect(() => { + if (selectedReportIdsRef.current.length === 0) { + setSelectionExplicitlyActivated(false); + } + }, [selectedReportId]); + const selectedReport = useMemo( () => reports.find((report) => report.id === selectedReportId) ?? null, [reports, selectedReportId], ); + const effectiveBulkIds = useMemo( + () => + getEffectiveBulkSelectionIds( + selectedReportIds, + selectedReportId, + selectionExplicitlyActivated, + ), + [selectedReportIds, selectedReportId, selectionExplicitlyActivated], + ); + + // Toggle a report's checkbox, handling the virtual → explicit mode transition. + // When the first explicit toggle happens in virtual mode (store empty, a report is open): + // - toggling a DIFFERENT report: seed the open report into the store too (keep it checked) + // - toggling the OPEN report itself: transition to explicit-empty (uncheck it) + const handleToggleReportSelection = useCallback( + (reportId: string) => { + if ( + !selectionExplicitlyActivatedRef.current && + selectedReportIdsRef.current.length === 0 + ) { + setSelectionExplicitlyActivated(true); + if ( + selectedReportIdRef.current !== null && + reportId !== selectedReportIdRef.current + ) { + // Seed the open report + add the newly toggled one + setSelectedReportIds([selectedReportIdRef.current, reportId]); + } + // If toggling the open report's own checkbox, the store stays empty + // and explicit = true → effective = [] (it becomes unchecked) + } else { + toggleReportSelection(reportId); + } + }, + [setSelectedReportIds, toggleReportSelection], + ); + + // Handle the select-all checkbox. Parent owns all state transitions. + const handleToggleSelectAll = useCallback( + (checked: boolean) => { + if (checked) { + setSelectedReportIds(reportsRef.current.map((r) => r.id)); + setSelectionExplicitlyActivated(true); + } else { + setSelectedReportIds([]); + if (!selectionExplicitlyActivatedRef.current) { + // Was in virtual mode (open report only virtually selected): + // close the report so there is truly nothing selected. + setSelectedReportId(null); + setSelectionExplicitlyActivated(false); + } + // If already in explicit mode, keep the flag true so the empty store + // means nothing selected — no fallback to the virtual open report. + } + }, + [setSelectedReportIds], + ); + // ── Sidebar resize ───────────────────────────────────────────────────── const sidebarWidth = useInboxSignalsSidebarStore((state) => state.width); const sidebarIsResizing = useInboxSignalsSidebarStore( @@ -279,14 +363,17 @@ export function InboxSignalsTab() { } else if (e.key === "ArrowUp") { e.preventDefault(); navigateReport(-1); - } else if (e.key === " " && selectedReportIdRef.current) { + } else if ( + (e.key === " " || e.key === "Enter") && + selectedReportIdRef.current + ) { e.preventDefault(); - toggleReportSelection(selectedReportIdRef.current); + handleToggleReportSelection(selectedReportIdRef.current); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [navigateReport, toggleReportSelection]); + }, [navigateReport, handleToggleReportSelection]); const searchDisabledReason = !hasReports && !searchQuery.trim() @@ -381,6 +468,8 @@ export function InboxSignalsTab() { readyCount={readyCount} processingCount={processingCount} reports={reports} + effectiveBulkIds={effectiveBulkIds} + onToggleSelectAll={handleToggleSelectAll} /> diff --git a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx index 4688766cb..51edbd0a4 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -1,9 +1,12 @@ +import { Button } from "@components/ui/Button"; import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; -import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { type SourceProduct, useInboxSignalsFilterStore, } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + INBOX_REFETCH_INTERVAL_MS, +} from "@features/inbox/utils/inboxConstants"; import { inboxStatusAccentCss, inboxStatusLabel, @@ -30,7 +33,6 @@ import { import { AlertDialog, Box, - Button, Checkbox, Flex, Popover, @@ -57,6 +59,10 @@ interface SignalsToolbarProps { searchDisabledReason?: string | null; hideFilters?: boolean; reports?: SignalReport[]; + /** Pre-computed effective bulk selection (store ids or virtual open-report fallback). */ + effectiveBulkIds?: string[]; + /** Called when the select-all checkbox is toggled. Parent owns all state transitions. */ + onToggleSelectAll?: (checked: boolean) => void; } type SortOption = { @@ -104,6 +110,8 @@ const FILTERABLE_STATUSES: SignalReportStatus[] = [ "potential", ]; +const inboxLivePollingTooltip = `Inbox refetches the report list about every ${(INBOX_REFETCH_INTERVAL_MS / 1000).toFixed(1)} seconds while this window is focused and Inbox is open. Refetching pauses when you switch to another app or navigate away from Inbox.`; + export function SignalsToolbar({ totalCount, filteredCount, @@ -114,17 +122,13 @@ export function SignalsToolbar({ searchDisabledReason, hideFilters, reports = [], + effectiveBulkIds = [], + onToggleSelectAll, }: SignalsToolbarProps) { const searchQuery = useInboxSignalsFilterStore((s) => s.searchQuery); const setSearchQuery = useInboxSignalsFilterStore((s) => s.setSearchQuery); const [showSuppressConfirm, setShowSuppressConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const selectedReportIds = useInboxReportSelectionStore( - (s) => s.selectedReportIds ?? [], - ); - const setSelectedReportIds = useInboxReportSelectionStore( - (s) => s.setSelectedReportIds, - ); const { selectedCount, @@ -140,7 +144,7 @@ export function SignalsToolbar({ snoozeSelected, deleteSelected, reingestSelected, - } = useInboxBulkActions(reports); + } = useInboxBulkActions(reports, effectiveBulkIds); const countLabel = isSearchActive ? `${filteredCount} of ${totalCount}` @@ -176,21 +180,13 @@ export function SignalsToolbar({ const visibleReportIds = reports.map((report) => report.id); const hasVisibleReports = visibleReportIds.length > 0; const selectedVisibleCount = visibleReportIds.filter((reportId) => - selectedReportIds.includes(reportId), + effectiveBulkIds.includes(reportId), ).length; const allVisibleSelected = hasVisibleReports && selectedVisibleCount === visibleReportIds.length; const someVisibleSelected = selectedVisibleCount > 0 && selectedVisibleCount < visibleReportIds.length; - const handleToggleSelectAll = (checked: boolean) => { - if (checked) { - setSelectedReportIds(visibleReportIds); - } else { - setSelectedReportIds([]); - } - }; - return ( <> {livePolling ? ( - + + + ) : null} {pipelineHint && !isSearchActive ? ( @@ -227,8 +225,26 @@ export function SignalsToolbar({ {!hideFilters && } - - + + + + {/* biome-ignore lint/a11y/noLabelWithoutControl: Radix Checkbox renders as button[role=checkbox] inside the label, which is valid */} + - - - - - - - {IS_DEV && ( - + {isSnoozing ? : } + Snooze + + + + {IS_DEV && ( + + )} +