From 2c5755600123727a4b91739fc91710431ffb547c Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Tue, 7 Apr 2026 20:05:54 +0200 Subject: [PATCH 1/2] Rework inbox bulk selection UX Made-with: Cursor --- .../inbox/components/InboxSignalsTab.tsx | 188 +++++++++++------- .../components/detail/MultiSelectStack.tsx | 173 ++++++++++++++++ .../inbox/components/list/ReportListPane.tsx | 18 +- .../inbox/components/list/ReportListRow.tsx | 149 ++++---------- .../inbox/components/list/SignalsToolbar.tsx | 48 +++-- .../components/utils/ReportCardContent.tsx | 113 +++++++++++ .../inbox/hooks/useInboxBulkActions.ts | 12 +- .../stores/inboxReportSelectionStore.test.ts | 84 +++++++- .../inbox/stores/inboxReportSelectionStore.ts | 57 +++++- 9 files changed, 619 insertions(+), 223 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx create mode 100644 apps/code/src/renderer/features/inbox/components/utils/ReportCardContent.tsx diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 8a5188136..e8c7e5b5d 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -26,7 +26,8 @@ import { Box, Flex, ScrollArea } from "@radix-ui/themes"; import type { SignalReportsQueryParams } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; +import { MultiSelectStack } from "./detail/MultiSelectStack"; import { ReportDetailPane } from "./detail/ReportDetailPane"; import { ReportListPane } from "./list/ReportListPane"; import { SignalsToolbar } from "./list/SignalsToolbar"; @@ -134,39 +135,78 @@ export function InboxSignalsTab() { [allReports], ); - // ── Selection state ───────────────────────────────────────────────────── - const [selectedReportId, setSelectedReportId] = useState(null); + // ── Selection state (unified — store is single source of truth) ───────── const selectedReportIds = useInboxReportSelectionStore( - (s) => s.selectedReportIds ?? [], + (s) => s.selectedReportIds, + ); + const setSelectedReportIds = useInboxReportSelectionStore( + (s) => s.setSelectedReportIds, ); const toggleReportSelection = useInboxReportSelectionStore( (s) => s.toggleReportSelection, ); + const selectRange = useInboxReportSelectionStore((s) => s.selectRange); const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); + const clearSelection = useInboxReportSelectionStore((s) => s.clearSelection); - useEffect(() => { - if (reports.length === 0) { - setSelectedReportId(null); - return; - } - if (!selectedReportId) { - return; - } - const selectedExists = reports.some( - (report) => report.id === selectedReportId, - ); - if (!selectedExists) { - setSelectedReportId(null); - } - }, [reports, selectedReportId]); + // Stable refs so callbacks don't need re-registration on every render + const selectedReportIdsRef = useRef(selectedReportIds); + selectedReportIdsRef.current = selectedReportIds; + const reportsRef = useRef(reports); + reportsRef.current = reports; + // Prune selection when visible reports change (e.g. filter/search) useEffect(() => { pruneSelection(reports.map((report) => report.id)); }, [reports, pruneSelection]); - const selectedReport = useMemo( - () => reports.find((report) => report.id === selectedReportId) ?? null, - [reports, selectedReportId], + // The report to show in the detail pane (only when exactly 1 is selected) + const selectedReport = useMemo(() => { + if (selectedReportIds.length !== 1) return null; + return reports.find((r) => r.id === selectedReportIds[0]) ?? null; + }, [reports, selectedReportIds]); + + // Reports for the multi-select stack (when 2+ selected) + const selectedReports = useMemo(() => { + if (selectedReportIds.length < 2) return []; + const idSet = new Set(selectedReportIds); + return reports.filter((r) => idSet.has(r.id)); + }, [reports, selectedReportIds]); + + // ── Click handler: plain / cmd / shift ────────────────────────────────── + const handleReportClick = useCallback( + (reportId: string, event: { metaKey: boolean; shiftKey: boolean }) => { + if (event.shiftKey) { + selectRange( + reportId, + reportsRef.current.map((r) => r.id), + ); + } else if (event.metaKey) { + toggleReportSelection(reportId); + } else if ( + selectedReportIdsRef.current.length === 1 && + selectedReportIdsRef.current[0] === reportId + ) { + // Plain click on the only selected report — deselect it + clearSelection(); + } else { + // Plain click — select only this report + setSelectedReportIds([reportId]); + } + }, + [selectRange, toggleReportSelection, setSelectedReportIds, clearSelection], + ); + + // Select-all checkbox + const handleToggleSelectAll = useCallback( + (checked: boolean) => { + if (checked) { + setSelectedReportIds(reportsRef.current.map((r) => r.id)); + } else { + clearSelection(); + } + }, + [setSelectedReportIds, clearSelection], ); // ── Sidebar resize ───────────────────────────────────────────────────── @@ -237,10 +277,6 @@ export function InboxSignalsTab() { const showTwoPaneLayout = hasMountedTwoPaneRef.current; // ── Arrow-key navigation between reports ────────────────────────────── - const reportsRef = useRef(reports); - reportsRef.current = reports; - const selectedReportIdRef = useRef(selectedReportId); - selectedReportIdRef.current = selectedReportId; const leftPaneRef = useRef(null); const focusListPane = useCallback(() => { @@ -252,41 +288,46 @@ export function InboxSignalsTab() { // Auto-focus the list pane when the two-pane layout appears useEffect(() => { if (showTwoPaneLayout) { - // Small delay to ensure the ref is mounted after conditional render focusListPane(); } }, [focusListPane, showTwoPaneLayout]); - const navigateReport = useCallback((direction: 1 | -1) => { - const list = reportsRef.current; - if (list.length === 0) return; - - const currentId = selectedReportIdRef.current; - const currentIndex = currentId - ? list.findIndex((r) => r.id === currentId) - : -1; - const nextIndex = - currentIndex === -1 - ? 0 - : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); - const nextId = list[nextIndex].id; - - setSelectedReportId(nextId); - - const container = leftPaneRef.current; - const row = container?.querySelector( - `[data-report-id="${nextId}"]`, - ); - const stickyHeader = container?.querySelector( - "[data-inbox-sticky-header]", - ); - - if (!row) return; - - const stickyHeaderHeight = stickyHeader?.offsetHeight ?? 0; - row.style.scrollMarginTop = `${stickyHeaderHeight}px`; - row.scrollIntoView({ block: "nearest" }); - }, []); + const navigateReport = useCallback( + (direction: 1 | -1) => { + 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) + : -1; + const nextIndex = + currentIndex === -1 + ? 0 + : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); + const nextId = list[nextIndex].id; + + setSelectedReportIds([nextId]); + + const container = leftPaneRef.current; + const row = container?.querySelector( + `[data-report-id="${nextId}"]`, + ); + const stickyHeader = container?.querySelector( + "[data-inbox-sticky-header]", + ); + + if (!row) return; + + const stickyHeaderHeight = stickyHeader?.offsetHeight ?? 0; + row.style.scrollMarginTop = `${stickyHeaderHeight}px`; + row.scrollIntoView({ block: "nearest" }); + }, + [setSelectedReportIds], + ); // Window-level keyboard handler so arrow keys work regardless of which // pane has focus — only suppressed inside interactive widgets. @@ -310,14 +351,17 @@ export function InboxSignalsTab() { } else if (e.key === "ArrowUp") { e.preventDefault(); navigateReport(-1); - } else if (e.key === " " && selectedReportIdRef.current) { + } else if ( + e.key === "Escape" && + selectedReportIdsRef.current.length > 0 + ) { e.preventDefault(); - toggleReportSelection(selectedReportIdRef.current); + clearSelection(); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [navigateReport, toggleReportSelection]); + }, [navigateReport, clearSelection]); const searchDisabledReason = !hasReports && !searchQuery.trim() @@ -366,11 +410,7 @@ export function InboxSignalsTab() { ) { return; } - if ( - target.closest( - "[data-report-id], button, [role='checkbox']", - ) - ) { + if (target.closest("[data-report-id], button")) { focusListPane(); } }} @@ -387,9 +427,7 @@ export function InboxSignalsTab() { } if ( target !== leftPaneRef.current && - target.closest( - "[data-report-id], button, [role='checkbox']", - ) + target.closest("[data-report-id], button") ) { focusListPane(); } @@ -413,6 +451,8 @@ export function InboxSignalsTab() { processingCount={processingCount} pipelinePausedUntil={signalProcessingState?.paused_until} reports={reports} + effectiveBulkIds={selectedReportIds} + onToggleSelectAll={handleToggleSelectAll} /> @@ -463,10 +502,15 @@ export function InboxSignalsTab() { position: "relative", }} > - {selectedReport ? ( + {selectedReports.length > 1 ? ( + + ) : selectedReport ? ( setSelectedReportId(null)} + onClose={clearSelection} /> ) : ( diff --git a/apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx b/apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx new file mode 100644 index 000000000..6ce49e553 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/detail/MultiSelectStack.tsx @@ -0,0 +1,173 @@ +import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; +import { Flex, Text } from "@radix-ui/themes"; +import type { SignalReport } from "@shared/types"; +import { AnimatePresence, motion } from "framer-motion"; +import { useCallback, useState } from "react"; + +interface MultiSelectStackProps { + reports: SignalReport[]; + onClearSelection: () => void; +} + +/** Maximum number of cards to render in the visual stack. */ +const MAX_VISIBLE_CARDS = 5; + +/** Vertical gap between cards when collapsed (just peeking). */ +const COLLAPSED_GAP = 14; +/** Vertical gap between cards when fanned open on hover. */ +const FANNED_GAP = 120; + +export function MultiSelectStack({ + reports, + onClearSelection, +}: MultiSelectStackProps) { + const visibleReports = reports.slice(0, MAX_VISIBLE_CARDS); + const overflowCount = reports.length - MAX_VISIBLE_CARDS; + + // Index of the card being hovered. null = collapsed stack. + const [hoveredIndex, setHoveredIndex] = useState(null); + + const handleCardHover = useCallback((index: number) => { + setHoveredIndex(index); + }, []); + + const handleStackLeave = useCallback(() => { + setHoveredIndex(null); + }, []); + + const cardCount = visibleReports.length; + const collapsedHeight = + 130 + (cardCount > 1 ? (cardCount - 1) * COLLAPSED_GAP : 0); + // When hovering, one card jumps out by FANNED_GAP instead of COLLAPSED_GAP + const hoveredHeight = collapsedHeight + (FANNED_GAP - COLLAPSED_GAP); + // Top card (last index) is already fully visible — no need to expand + const isHoveringNonTopCard = + hoveredIndex !== null && hoveredIndex < cardCount - 1; + const stackHeight = isHoveringNonTopCard ? hoveredHeight : collapsedHeight; + + return ( + + {/* ── Stacked cards ──────────────────────────────────────────── */} + + + {visibleReports.map((report, i) => { + const distanceFromFront = cardCount - 1 - i; + + // Don't fan when hovering the topmost card — it's already visible + const hoveredDistFromFront = + hoveredIndex !== null && hoveredIndex < cardCount - 1 + ? cardCount - 1 - hoveredIndex + : null; + + // Hovered card offset = collapsed cards in front + one fanned jump. + // Cards in front stay collapsed. Cards behind collapse behind the hovered card. + let yOffset: number; + if (hoveredDistFromFront !== null) { + // The hovered card's position: cards in front at COLLAPSED_GAP + one FANNED_GAP + const hoveredY = + (hoveredDistFromFront > 0 + ? (hoveredDistFromFront - 1) * COLLAPSED_GAP + FANNED_GAP + : 0) * -1; + + if (distanceFromFront === hoveredDistFromFront) { + yOffset = hoveredY; + } else if (distanceFromFront < hoveredDistFromFront) { + // In front of hovered card — collapsed at the front + yOffset = distanceFromFront * -COLLAPSED_GAP; + } else { + // Behind the hovered card — collapsed behind it + const behindBy = distanceFromFront - hoveredDistFromFront; + yOffset = hoveredY + behindBy * -COLLAPSED_GAP; + } + } else { + yOffset = distanceFromFront * -COLLAPSED_GAP; + } + + const scale = 1 - distanceFromFront * 0.025; + + return ( + handleCardHover(i)} + style={{ + position: "absolute", + bottom: 0, + left: 0, + right: 0, + zIndex: i, + transformOrigin: "bottom center", + cursor: "default", + }} + > +
+ +
+
+ ); + })} +
+
+ + {/* ── Summary text ───────────────────────────────────────────── */} + + + + {reports.length} reports selected + + {overflowCount > 0 && ( + + +{overflowCount} more not shown + + )} + + + +
+ ); +} diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx index b0e9b0845..78781145a 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx @@ -65,9 +65,11 @@ interface ReportListPaneProps { hasSignalSources: boolean; searchQuery: string; hasActiveFilters: boolean; - selectedReportId: string | null; selectedReportIds: string[]; - onSelectReport: (id: string) => void; + onReportClick: ( + id: string, + event: { metaKey: boolean; shiftKey: boolean }, + ) => void; onToggleReportSelection: (id: string) => void; } @@ -84,9 +86,8 @@ export function ReportListPane({ hasSignalSources, searchQuery, hasActiveFilters, - selectedReportId, selectedReportIds = [], - onSelectReport, + onReportClick, onToggleReportSelection, }: ReportListPaneProps) { // ── Loading skeleton ──────────────────────────────────────────────────── @@ -160,6 +161,9 @@ export function ReportListPane({ ); } + const selectedIdSet = new Set(selectedReportIds); + const showCheckboxes = selectedReportIds.length > 1; + // ── Report list ───────────────────────────────────────────────────────── return ( <> @@ -168,9 +172,9 @@ export function ReportListPane({ key={report.id} report={report} index={index} - isSelected={selectedReportId === report.id} - isChecked={selectedReportIds.includes(report.id)} - onClick={() => onSelectReport(report.id)} + isSelected={selectedIdSet.has(report.id)} + showCheckbox={showCheckboxes} + onClick={(e) => onReportClick(report.id, e)} onToggleChecked={() => onToggleReportSelection(report.id)} /> ))} diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx index a1227d0cb..045f1ebe4 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx @@ -1,9 +1,7 @@ -import { SignalReportPriorityBadge } from "@features/inbox/components/utils/SignalReportPriorityBadge"; -import { SignalReportStatusBadge } from "@features/inbox/components/utils/SignalReportStatusBadge"; -import { SignalReportSummaryMarkdown } from "@features/inbox/components/utils/SignalReportSummaryMarkdown"; +import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; -import { EyeIcon } from "@phosphor-icons/react"; -import { Checkbox, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { FileTextIcon } from "@phosphor-icons/react"; +import { Checkbox, Flex } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; import { motion } from "framer-motion"; import type { KeyboardEvent, MouseEvent } from "react"; @@ -11,8 +9,8 @@ import type { KeyboardEvent, MouseEvent } from "react"; interface ReportListRowProps { report: SignalReport; isSelected: boolean; - isChecked: boolean; - onClick: () => void; + showCheckbox: boolean; + onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; onToggleChecked: () => void; index: number; } @@ -20,21 +18,11 @@ interface ReportListRowProps { export function ReportListRow({ report, isSelected, - isChecked, + showCheckbox, onClick, onToggleChecked, index, }: ReportListRowProps) { - const updatedAtLabel = new Date(report.updated_at).toLocaleDateString( - undefined, - { - month: "short", - day: "numeric", - }, - ); - - const isReady = report.status === "ready"; - const isInteractiveTarget = (target: EventTarget | null): boolean => { return ( target instanceof HTMLElement && @@ -46,21 +34,19 @@ export function ReportListRow({ if (isInteractiveTarget(e.target)) { return; } - onClick(); - }; - - const handleToggleChecked = (e: MouseEvent | KeyboardEvent): void => { - e.stopPropagation(); - onToggleChecked(); + onClick({ metaKey: e.metaKey, shiftKey: e.shiftKey }); }; const rowBgClass = isSelected ? "bg-gray-3" - : isChecked - ? "bg-gray-2" - : report.is_suggested_reviewer - ? "bg-blue-2" - : ""; + : report.is_suggested_reviewer + ? "bg-blue-2" + : ""; + + const firstProduct = (report.source_products ?? [])[0]; + const sourceProductMeta = firstProduct + ? SOURCE_PRODUCT_META[firstProduct] + : null; return ( - - - + + + {showCheckbox ? ( { e.preventDefault(); @@ -115,86 +102,24 @@ export function ReportListRow({ }} onCheckedChange={() => onToggleChecked()} aria-label={ - isChecked + isSelected ? "Unselect report from bulk actions" : "Select report for bulk actions" } /> - - - - - {(report.source_products ?? []).length > 0 && ( - - {(report.source_products ?? []).map((sp) => { - const meta = SOURCE_PRODUCT_META[sp]; - if (!meta) return null; - const { Icon } = meta; - return ( - - - - ); - })} - - )} - - - - {report.title ?? "Untitled signal"} - - - - {report.is_suggested_reviewer && ( - - - - - - )} - - - -
- -
-
-
- - - - {updatedAtLabel} - + ) : sourceProductMeta ? ( + + + + ) : ( + + + + )} +
+ +
); 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 3f9db04d3..5b0fa80cd 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -103,7 +103,7 @@ export function SignalsToolbar({ snoozeSelected, deleteSelected, reingestSelected, - } = useInboxBulkActions(reports); + } = useInboxBulkActions(reports, effectiveBulkIds); const countLabel = isSearchActive ? `${filteredCount} of ${totalCount}` @@ -219,23 +219,35 @@ export function SignalsToolbar({ - {/* biome-ignore lint/a11y/noLabelWithoutControl: Radix Checkbox renders as button[role=checkbox] inside the label, which is valid */} - + + {allVisibleSelected || someVisibleSelected + ? "Click to unselect all" + : "Click to select all"} +
+ Select items in bulk with Shift and {"\u2318"} + + } + > + {/* biome-ignore lint/a11y/noLabelWithoutControl: Radix Checkbox renders as button[role=checkbox] inside the label, which is valid */} + +