From fc531e9fc5d618fec788cfef6816ccae89666ed2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 7 Apr 2026 09:00:00 +0000 Subject: [PATCH 1/2] refactor: rename SourceProductIcons.tsx to source-product-icons.tsx PascalCase file names are reserved for files exporting React components. This file only exports constants, so use kebab-case instead. Co-authored-by: Michael Matloka --- .../src/renderer/features/inbox/components/InboxEmptyStates.tsx | 2 +- .../renderer/features/inbox/components/detail/SignalCard.tsx | 2 +- .../src/renderer/features/inbox/components/list/ReportCard.tsx | 2 +- .../utils/{SourceProductIcons.tsx => source-product-icons.tsx} | 0 4 files changed, 3 insertions(+), 3 deletions(-) rename apps/code/src/renderer/features/inbox/components/utils/{SourceProductIcons.tsx => source-product-icons.tsx} (100%) diff --git a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx index 5a6c8d7c7..fc434b8be 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx @@ -1,5 +1,5 @@ import { AnimatedEllipsis } from "@features/inbox/components/utils/AnimatedEllipsis"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/SourceProductIcons"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { ArrowDownIcon } from "@phosphor-icons/react"; import { Box, Button, Flex, Text, Tooltip } from "@radix-ui/themes"; import explorerHog from "@renderer/assets/images/explorer-hog.png"; diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 74551e473..2b19ede6a 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -1,5 +1,5 @@ import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; -import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/SourceProductIcons"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { ArrowSquareOutIcon, CaretDownIcon, diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx index 57e5b5157..093b8e059 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx @@ -1,7 +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 { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/SourceProductIcons"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { inboxStatusAccentCss } from "@features/inbox/utils/inboxSort"; import { EyeIcon } from "@phosphor-icons/react"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; diff --git a/apps/code/src/renderer/features/inbox/components/utils/SourceProductIcons.tsx b/apps/code/src/renderer/features/inbox/components/utils/source-product-icons.tsx similarity index 100% rename from apps/code/src/renderer/features/inbox/components/utils/SourceProductIcons.tsx rename to apps/code/src/renderer/features/inbox/components/utils/source-product-icons.tsx From f32b1565ad8f2c4de0f3f94977b0a100ec23d87f Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Tue, 7 Apr 2026 14:35:46 +0300 Subject: [PATCH 2/2] sig: add modifying report state via inbox --- apps/code/src/renderer/api/posthogClient.ts | 88 +++++ .../inbox/components/InboxSignalsTab.tsx | 76 +++- .../inbox/components/list/ReportCard.tsx | 168 --------- .../inbox/components/list/ReportListPane.tsx | 10 +- .../inbox/components/list/ReportListRow.tsx | 215 +++++++++++ .../inbox/components/list/SignalsToolbar.tsx | 351 +++++++++++++++--- .../inbox/hooks/useInboxBulkActions.ts | 303 +++++++++++++++ .../stores/inboxReportSelectionStore.test.ts | 85 +++++ .../inbox/stores/inboxReportSelectionStore.ts | 40 ++ 9 files changed, 1109 insertions(+), 227 deletions(-) delete mode 100644 apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx create mode 100644 apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx create mode 100644 apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts create mode 100644 apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts create mode 100644 apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 49ff66b9f..683ca3341 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,9 +1,11 @@ import type { SandboxEnvironment, SandboxEnvironmentInput, + SignalReport, SignalReportArtefact, SignalReportArtefactsResponse, SignalReportSignalsResponse, + SignalReportStatus, SignalReportsQueryParams, SignalReportsResponse, SuggestedReviewersArtefact, @@ -1068,6 +1070,92 @@ export class PostHogAPIClient { } } + async updateSignalReportState( + reportId: string, + input: { + state: Extract; + snooze_for?: number; + reset_weight?: boolean; + error?: string; + }, + ): Promise { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/state/`, + ); + const path = `/api/projects/${teamId}/signal_reports/${reportId}/state/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + overrides: { + body: JSON.stringify(input), + }, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to update signal report state"); + } + + return (await response.json()) as SignalReport; + } + + async deleteSignalReport(reportId: string): Promise<{ + status: "deletion_started" | "already_running"; + report_id: string; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/`, + ); + const path = `/api/projects/${teamId}/signal_reports/${reportId}/`; + + const response = await this.api.fetcher.fetch({ + method: "delete", + url, + path, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to delete signal report"); + } + + return (await response.json()) as { + status: "deletion_started" | "already_running"; + report_id: string; + }; + } + + async reingestSignalReport(reportId: string): Promise<{ + status: "reingestion_started" | "already_running"; + report_id: string; + }> { + const teamId = await this.getTeamId(); + const url = new URL( + `${this.api.baseUrl}/api/projects/${teamId}/signal_reports/${reportId}/reingest/`, + ); + const path = `/api/projects/${teamId}/signal_reports/${reportId}/reingest/`; + + const response = await this.api.fetcher.fetch({ + method: "post", + url, + path, + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(errorText || "Failed to reingest signal report"); + } + + return (await response.json()) as { + status: "reingestion_started" | "already_running"; + report_id: string; + }; + } + async getMcpServers(): Promise { const teamId = await this.getTeamId(); const url = new URL( diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index c2bbab2e7..f53798fb0 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -8,6 +8,7 @@ import { InboxLiveRail } from "@features/inbox/components/InboxLiveRail"; import { InboxSourcesDialog } from "@features/inbox/components/InboxSourcesDialog"; import { useInboxReportsInfinite } from "@features/inbox/hooks/useInboxReports"; import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; +import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; @@ -107,6 +108,13 @@ export function InboxSignalsTab() { // ── Selection state ───────────────────────────────────────────────────── const [selectedReportId, setSelectedReportId] = useState(null); + const selectedReportIds = useInboxReportSelectionStore( + (s) => s.selectedReportIds ?? [], + ); + const toggleReportSelection = useInboxReportSelectionStore( + (s) => s.toggleReportSelection, + ); + const pruneSelection = useInboxReportSelectionStore((s) => s.pruneSelection); useEffect(() => { if (reports.length === 0) { @@ -124,6 +132,10 @@ export function InboxSignalsTab() { } }, [reports, selectedReportId]); + useEffect(() => { + pruneSelection(reports.map((report) => report.id)); + }, [reports, pruneSelection]); + const selectedReport = useMemo( () => reports.find((report) => report.id === selectedReportId) ?? null, [reports, selectedReportId], @@ -201,19 +213,24 @@ export function InboxSignalsTab() { selectedReportIdRef.current = selectedReportId; const leftPaneRef = useRef(null); + const focusListPane = useCallback(() => { + requestAnimationFrame(() => { + leftPaneRef.current?.focus(); + }); + }, []); + // 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 - requestAnimationFrame(() => { - leftPaneRef.current?.focus(); - }); + focusListPane(); } - }, [showTwoPaneLayout]); + }, [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) @@ -223,10 +240,22 @@ export function InboxSignalsTab() { ? 0 : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); const nextId = list[nextIndex].id; + setSelectedReportId(nextId); - leftPaneRef.current - ?.querySelector(`[data-report-id="${nextId}"]`) - ?.scrollIntoView({ block: "nearest" }); + + 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" }); }, []); // Window-level keyboard handler so arrow keys work regardless of which @@ -243,6 +272,7 @@ export function InboxSignalsTab() { const target = e.target as HTMLElement; if (target.closest("input, select, textarea")) return; + if (e.key === " " && target.closest("button, [role='checkbox']")) return; if (e.key === "ArrowDown") { e.preventDefault(); @@ -250,11 +280,14 @@ export function InboxSignalsTab() { } else if (e.key === "ArrowUp") { e.preventDefault(); navigateReport(-1); + } else if (e.key === " " && selectedReportIdRef.current) { + e.preventDefault(); + toggleReportSelection(selectedReportIdRef.current); } }; window.addEventListener("keydown", handler); return () => window.removeEventListener("keydown", handler); - }, [navigateReport]); + }, [navigateReport, toggleReportSelection]); const searchDisabledReason = !hasReports && !searchQuery.trim() @@ -287,11 +320,33 @@ export function InboxSignalsTab() { { + const target = e.target as HTMLElement; + if ( + target.closest( + "[data-report-id], button, input, select, textarea, [role='checkbox']", + ) + ) { + focusListPane(); + } + }} + onFocusCapture={(e) => { + const target = e.target as HTMLElement; + if ( + target !== leftPaneRef.current && + target.closest( + "[data-report-id], button, input, select, textarea, [role='checkbox']", + ) + ) { + focusListPane(); + } + }} > diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx deleted file mode 100644 index 093b8e059..000000000 --- a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx +++ /dev/null @@ -1,168 +0,0 @@ -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 { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; -import { inboxStatusAccentCss } from "@features/inbox/utils/inboxSort"; -import { EyeIcon } from "@phosphor-icons/react"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalReport } from "@shared/types"; -import { motion } from "framer-motion"; -import type { KeyboardEvent, MouseEvent } from "react"; - -interface ReportCardProps { - report: SignalReport; - isSelected: boolean; - onClick: () => void; - index: number; -} - -export function ReportCard({ - report, - isSelected, - onClick, - index, -}: ReportCardProps) { - const updatedAtLabel = new Date(report.updated_at).toLocaleDateString( - undefined, - { - month: "short", - day: "numeric", - }, - ); - - const isStrongSignal = report.total_weight >= 65 || report.signal_count >= 20; - const isMediumSignal = report.total_weight >= 30 || report.signal_count >= 6; - const strengthColor = isStrongSignal - ? "var(--green-9)" - : isMediumSignal - ? "var(--yellow-9)" - : "var(--gray-8)"; - const strengthLabel = isStrongSignal - ? "strong" - : isMediumSignal - ? "medium" - : "light"; - - const accent = inboxStatusAccentCss(report.status); - const isReady = report.status === "ready"; - - const handleActivate = (e: MouseEvent | KeyboardEvent): void => { - if ((e.target as HTMLElement).closest("a")) { - return; - } - onClick(); - }; - - return ( - { - if (e.key === "Enter" || e.key === " ") { - e.preventDefault(); - handleActivate(e); - } - }} - className="w-full cursor-pointer overflow-hidden border-gray-5 border-b py-2 pr-3 pl-2 text-left transition-colors hover:bg-gray-2" - style={{ - backgroundColor: isSelected - ? "var(--gray-3)" - : report.is_suggested_reviewer - ? "var(--blue-2)" - : "transparent", - boxShadow: `inset 3px 0 0 0 ${accent}`, - }} - > - - - - {/* Source product icons — pt-1 (4px) centers 12px icons - with the title's 13px/~20px effective line height */} - - {(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 && ( - - - - - - )} - - - {/* Summary is outside the title row so wrapped lines align with title text (bullet + gap), not the card edge */} -
- -
-
- - - {updatedAtLabel} - - - w:{report.total_weight.toFixed(2)} - - -
-
- ); -} 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 f00360be2..34397f582 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx @@ -6,7 +6,7 @@ import { import { Box, Button, Flex, Text } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; import { useEffect, useRef } from "react"; -import { ReportCard } from "./ReportCard"; +import { ReportListRow } from "./ReportListRow"; // ── LoadMoreTrigger (intersection observer for infinite scroll) ────────────── @@ -66,7 +66,9 @@ interface ReportListPaneProps { searchQuery: string; hasActiveFilters: boolean; selectedReportId: string | null; + selectedReportIds: string[]; onSelectReport: (id: string) => void; + onToggleReportSelection: (id: string) => void; } export function ReportListPane({ @@ -83,7 +85,9 @@ export function ReportListPane({ searchQuery, hasActiveFilters, selectedReportId, + selectedReportIds = [], onSelectReport, + onToggleReportSelection, }: ReportListPaneProps) { // ── Loading skeleton ──────────────────────────────────────────────────── if (isLoading && allReports.length === 0 && hasSignalSources) { @@ -160,12 +164,14 @@ export function ReportListPane({ return ( <> {reports.map((report, index) => ( - onSelectReport(report.id)} + onToggleChecked={() => onToggleReportSelection(report.id)} /> ))} void; + onToggleChecked: () => void; + index: number; +} + +export function ReportListRow({ + report, + isSelected, + isChecked, + onClick, + onToggleChecked, + index, +}: ReportListRowProps) { + const updatedAtLabel = new Date(report.updated_at).toLocaleDateString( + undefined, + { + month: "short", + day: "numeric", + }, + ); + + const isStrongSignal = report.total_weight >= 65 || report.signal_count >= 20; + const isMediumSignal = report.total_weight >= 30 || report.signal_count >= 6; + const strengthColor = isStrongSignal + ? "var(--green-9)" + : isMediumSignal + ? "var(--yellow-9)" + : "var(--gray-8)"; + const strengthLabel = isStrongSignal + ? "strong" + : isMediumSignal + ? "medium" + : "light"; + + const isReady = report.status === "ready"; + + const isInteractiveTarget = (target: EventTarget | null): boolean => { + return ( + target instanceof HTMLElement && + !!target.closest("a, button, input, select, textarea, [role='checkbox']") + ); + }; + + const handleActivate = (e: MouseEvent | KeyboardEvent): void => { + if (isInteractiveTarget(e.target)) { + return; + } + onClick(); + }; + + const handleToggleChecked = (e: MouseEvent | KeyboardEvent): void => { + e.stopPropagation(); + onToggleChecked(); + }; + + return ( + { + e.preventDefault(); + }} + onClick={handleActivate} + onKeyDown={(e: KeyboardEvent) => { + if (isInteractiveTarget(e.target)) { + return; + } + + if (e.key === "Enter") { + e.preventDefault(); + handleActivate(e); + } else if (e.key === " ") { + e.preventDefault(); + handleToggleChecked(e); + } + }} + className="w-full cursor-pointer overflow-hidden border-gray-5 border-b py-2 pr-3 pl-2 text-left transition-colors hover:bg-gray-2" + style={{ + backgroundColor: isSelected + ? "var(--gray-3)" + : isChecked + ? "var(--gray-2)" + : report.is_suggested_reviewer + ? "var(--blue-2)" + : "transparent", + }} + > + + + + { + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + onCheckedChange={() => onToggleChecked()} + aria-label={ + isChecked + ? "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} + + +
+
+ ); +} 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 4601e8e18..b448c9a6e 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -1,3 +1,5 @@ +import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; +import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; import { type SourceProduct, useInboxSignalsFilterStore, @@ -7,26 +9,43 @@ import { inboxStatusLabel, } from "@features/inbox/utils/inboxSort"; import { + ArrowClockwiseIcon, BrainIcon, BugIcon, CalendarPlus, Check, Clock, + EyeSlashIcon, FunnelSimple as FunnelSimpleIcon, GithubLogoIcon, KanbanIcon, ListNumbers, MagnifyingGlass, + PauseIcon, TicketIcon, + TrashIcon, TrendUp, VideoIcon, } from "@phosphor-icons/react"; -import { Box, Flex, Popover, Text, TextField, Tooltip } from "@radix-ui/themes"; +import { + AlertDialog, + Box, + Button, + Checkbox, + Flex, + Popover, + Spinner, + Text, + TextField, + Tooltip, +} from "@radix-ui/themes"; +import { IS_DEV } from "@shared/constants/environment"; import type { + SignalReport, SignalReportOrderingField, SignalReportStatus, } from "@shared/types"; -import type { KeyboardEvent } from "react"; +import { type KeyboardEvent, useState } from "react"; interface SignalsToolbarProps { totalCount: number; @@ -37,6 +56,7 @@ interface SignalsToolbarProps { processingCount?: number; searchDisabledReason?: string | null; hideFilters?: boolean; + reports?: SignalReport[]; } type SortOption = { @@ -93,9 +113,34 @@ export function SignalsToolbar({ processingCount = 0, searchDisabledReason, hideFilters, + reports = [], }: 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, + canSuppress, + canSnooze, + canDelete, + canReingest, + isSuppressing, + isSnoozing, + isDeleting, + isReingesting, + suppressSelected, + snoozeSelected, + deleteSelected, + reingestSelected, + } = useInboxBulkActions(reports); const countLabel = isSearchActive ? `${filteredCount} of ${totalCount}` @@ -106,56 +151,266 @@ export function SignalsToolbar({ ? `${readyCount} ready · ${processingCount} in pipeline` : null; + const handleConfirmSuppress = async () => { + const ok = await suppressSelected(); + if (ok) { + setShowSuppressConfirm(false); + } + }; + + const handleConfirmDelete = async () => { + const ok = await deleteSelected(); + if (ok) { + setShowDeleteConfirm(false); + } + }; + + const handleSnooze = async () => { + await snoozeSelected(); + }; + + const handleReingest = async () => { + await reingestSelected(); + }; + + const visibleReportIds = reports.map((report) => report.id); + const hasVisibleReports = visibleReportIds.length > 0; + const selectedVisibleCount = visibleReportIds.filter((reportId) => + selectedReportIds.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 ( - - - - - - Reports ({countLabel}) - - {livePolling ? ( - + <> + + + + + + Reports ({countLabel}) + + {livePolling ? ( + + ) : null} + + {pipelineHint && !isSearchActive ? ( + + {pipelineHint} + ) : null} - {pipelineHint && !isSearchActive ? ( - - {pipelineHint} - - ) : null} + {!hideFilters && } - {!hideFilters && } + + + + + handleToggleSelectAll(checked === true) + } + aria-label="Select all visible reports" + /> + + + + + {selectedCount > 0 && ( + + + + {selectedCount} selected + + + + + + + + + + + + + {IS_DEV && ( + + )} + + + )} - - + + + + + + + Suppress reports + + + + + Suppressing a report causes all future signals matched to that + report to be ignored. Are you sure? + + + + + + + + + + + + + + + + + + + Delete reports + + + + Delete this report? + + + + + + + + + + + + ); } @@ -216,7 +471,7 @@ function FilterSortMenu() { e.key === "ArrowDown" ? (currentIndex + 1) % buttons.length : (currentIndex - 1 + buttons.length) % buttons.length; - buttons[next].focus(); + buttons[next]?.focus(); }; return ( diff --git a/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts b/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts new file mode 100644 index 000000000..4164ff925 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useInboxBulkActions.ts @@ -0,0 +1,303 @@ +import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; +import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; +import type { SignalReport } from "@shared/types"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback, useMemo } from "react"; +import { toast } from "sonner"; + +type BulkActionName = "suppress" | "snooze" | "delete" | "reingest"; + +interface BulkActionResult { + successCount: number; + failureCount: number; +} + +const inboxQueryKey = ["inbox", "signal-reports"] as const; + +const suppressibleStatuses = new Set([ + "potential", + "candidate", + "in_progress", + "pending_input", + "ready", + "failed", +]); + +const snoozableStatuses = new Set([ + "in_progress", + "ready", +]); + +type SelectedReportEligibility = { + selectedReports: SignalReport[]; + selectedIds: string[]; + selectedCount: number; + canSuppress: boolean; + canSnooze: boolean; + canDelete: boolean; + canReingest: boolean; +}; + +function formatBulkActionSummary( + action: BulkActionName, + result: BulkActionResult, +): string { + const { successCount, failureCount } = result; + const noun = + action === "suppress" + ? "report suppressed" + : action === "snooze" + ? "report snoozed" + : action === "delete" + ? "report deleted" + : "report reingested"; + + const pluralized = successCount === 1 ? noun : `${noun}s`; + + if (failureCount === 0) { + return `${successCount} ${pluralized}`; + } + + return `${successCount} ${pluralized}, ${failureCount} failed`; +} + +function getSelectedReportEligibility( + reports: SignalReport[], + selectedIds: string[], +): SelectedReportEligibility { + const selectedIdSet = new Set(selectedIds); + const selectedReports = reports.filter((report) => + selectedIdSet.has(report.id), + ); + const selectedCount = selectedReports.length; + + return { + selectedReports, + selectedIds: selectedReports.map((report) => report.id), + selectedCount, + canSuppress: + selectedCount > 0 && + selectedReports.every((report) => + suppressibleStatuses.has(report.status), + ), + canSnooze: + selectedCount > 0 && + selectedReports.every((report) => snoozableStatuses.has(report.status)), + canDelete: selectedCount > 0, + canReingest: selectedCount > 0, + }; +} + +export function useInboxBulkActions(reports: SignalReport[]) { + const queryClient = useQueryClient(); + const selectedReportIds = useInboxReportSelectionStore( + (state) => state.selectedReportIds ?? [], + ); + const clearSelection = useInboxReportSelectionStore( + (state) => state.clearSelection, + ); + + const eligibility = useMemo( + () => getSelectedReportEligibility(reports, selectedReportIds), + [reports, selectedReportIds], + ); + + const invalidateInboxQueries = useCallback(async () => { + await queryClient.invalidateQueries({ + queryKey: inboxQueryKey, + exact: false, + }); + }, [queryClient]); + + const suppressMutation = useAuthenticatedMutation( + async (client, reportIds: string[]) => { + const results = await Promise.allSettled( + reportIds.map((reportId) => + client.updateSignalReportState(reportId, { state: "suppressed" }), + ), + ); + + const successCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + + return { + successCount, + failureCount: results.length - successCount, + }; + }, + { + onSuccess: async (result) => { + await invalidateInboxQueries(); + clearSelection(); + + if (result.failureCount > 0) { + toast.error(formatBulkActionSummary("suppress", result)); + return; + } + + toast.success(formatBulkActionSummary("suppress", result)); + }, + onError: (error) => { + toast.error(error.message || "Failed to suppress reports"); + }, + }, + ); + + const snoozeMutation = useAuthenticatedMutation( + async (client, reportIds: string[]) => { + const results = await Promise.allSettled( + reportIds.map((reportId) => + client.updateSignalReportState(reportId, { + state: "potential", + snooze_for: 1, + }), + ), + ); + + const successCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + + return { + successCount, + failureCount: results.length - successCount, + }; + }, + { + onSuccess: async (result) => { + await invalidateInboxQueries(); + clearSelection(); + + if (result.failureCount > 0) { + toast.error(formatBulkActionSummary("snooze", result)); + return; + } + + toast.success(formatBulkActionSummary("snooze", result)); + }, + onError: (error) => { + toast.error(error.message || "Failed to snooze reports"); + }, + }, + ); + + const deleteMutation = useAuthenticatedMutation( + async (client, reportIds: string[]) => { + const results = await Promise.allSettled( + reportIds.map((reportId) => client.deleteSignalReport(reportId)), + ); + + const successCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + + return { + successCount, + failureCount: results.length - successCount, + }; + }, + { + onSuccess: async (result) => { + await invalidateInboxQueries(); + clearSelection(); + + if (result.failureCount > 0) { + toast.error(formatBulkActionSummary("delete", result)); + return; + } + + toast.success(formatBulkActionSummary("delete", result)); + }, + onError: (error) => { + toast.error(error.message || "Failed to delete reports"); + }, + }, + ); + + const reingestMutation = useAuthenticatedMutation( + async (client, reportIds: string[]) => { + const results = await Promise.allSettled( + reportIds.map((reportId) => client.reingestSignalReport(reportId)), + ); + + const successCount = results.filter( + (result) => result.status === "fulfilled", + ).length; + + return { + successCount, + failureCount: results.length - successCount, + }; + }, + { + onSuccess: async (result) => { + await invalidateInboxQueries(); + clearSelection(); + + if (result.failureCount > 0) { + toast.error(formatBulkActionSummary("reingest", result)); + return; + } + + toast.success(formatBulkActionSummary("reingest", result)); + }, + onError: (error) => { + toast.error(error.message || "Failed to reingest reports"); + }, + }, + ); + + const suppressSelected = useCallback(async () => { + if (!eligibility.canSuppress) { + return false; + } + + await suppressMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [eligibility.canSuppress, eligibility.selectedIds, suppressMutation]); + + const snoozeSelected = useCallback(async () => { + if (!eligibility.canSnooze) { + return false; + } + + await snoozeMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [eligibility.canSnooze, eligibility.selectedIds, snoozeMutation]); + + const deleteSelected = useCallback(async () => { + if (!eligibility.canDelete) { + return false; + } + + await deleteMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [deleteMutation, eligibility.canDelete, eligibility.selectedIds]); + + const reingestSelected = useCallback(async () => { + if (!eligibility.canReingest) { + return false; + } + + await reingestMutation.mutateAsync(eligibility.selectedIds); + return true; + }, [eligibility.canReingest, eligibility.selectedIds, reingestMutation]); + + return { + selectedReports: eligibility.selectedReports, + selectedCount: eligibility.selectedCount, + canSuppress: eligibility.canSuppress, + canSnooze: eligibility.canSnooze, + canDelete: eligibility.canDelete, + canReingest: eligibility.canReingest, + isSuppressing: suppressMutation.isPending, + isSnoozing: snoozeMutation.isPending, + isDeleting: deleteMutation.isPending, + isReingesting: reingestMutation.isPending, + suppressSelected, + snoozeSelected, + deleteSelected, + reingestSelected, + }; +} diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts new file mode 100644 index 000000000..b6ef85766 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useInboxReportSelectionStore } from "./inboxReportSelectionStore"; + +describe("inboxReportSelectionStore", () => { + beforeEach(() => { + useInboxReportSelectionStore.setState({ + selectedReportIds: [], + }); + }); + + it("starts empty", () => { + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + [], + ); + }); + + it("setSelectedReportIds de-duplicates ids", () => { + useInboxReportSelectionStore + .getState() + .setSelectedReportIds(["r1", "r2", "r1", "r3", "r2"]); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual([ + "r1", + "r2", + "r3", + ]); + }); + + it("toggleReportSelection adds an unselected report", () => { + useInboxReportSelectionStore.getState().toggleReportSelection("r1"); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual([ + "r1", + ]); + }); + + it("toggleReportSelection removes a selected report", () => { + useInboxReportSelectionStore.setState({ + selectedReportIds: ["r1", "r2"], + }); + + useInboxReportSelectionStore.getState().toggleReportSelection("r1"); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual([ + "r2", + ]); + }); + + it("isReportSelected reflects selection state", () => { + useInboxReportSelectionStore.setState({ + selectedReportIds: ["r2"], + }); + + expect(useInboxReportSelectionStore.getState().isReportSelected("r1")).toBe( + false, + ); + expect(useInboxReportSelectionStore.getState().isReportSelected("r2")).toBe( + true, + ); + }); + + it("clearSelection clears all selected reports", () => { + useInboxReportSelectionStore.setState({ + selectedReportIds: ["r1", "r2"], + }); + + useInboxReportSelectionStore.getState().clearSelection(); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual( + [], + ); + }); + + it("pruneSelection keeps only visible report ids", () => { + useInboxReportSelectionStore.setState({ + selectedReportIds: ["r1", "r2", "r3"], + }); + + useInboxReportSelectionStore.getState().pruneSelection(["r2", "r4"]); + + expect(useInboxReportSelectionStore.getState().selectedReportIds).toEqual([ + "r2", + ]); + }); +}); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts new file mode 100644 index 000000000..26bc8fd52 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/stores/inboxReportSelectionStore.ts @@ -0,0 +1,40 @@ +import { create } from "zustand"; + +interface InboxReportSelectionState { + selectedReportIds: string[]; +} + +interface InboxReportSelectionActions { + setSelectedReportIds: (reportIds: string[]) => void; + toggleReportSelection: (reportId: string) => void; + isReportSelected: (reportId: string) => boolean; + clearSelection: () => void; + pruneSelection: (visibleReportIds: string[]) => void; +} + +type InboxReportSelectionStore = InboxReportSelectionState & + InboxReportSelectionActions; + +export const useInboxReportSelectionStore = create()( + (set, get) => ({ + selectedReportIds: [], + setSelectedReportIds: (reportIds) => + set({ selectedReportIds: Array.from(new Set(reportIds)) }), + toggleReportSelection: (reportId) => + set((state) => ({ + selectedReportIds: state.selectedReportIds.includes(reportId) + ? state.selectedReportIds.filter((id) => id !== reportId) + : [...state.selectedReportIds, reportId], + })), + isReportSelected: (reportId) => get().selectedReportIds.includes(reportId), + clearSelection: () => set({ selectedReportIds: [] }), + pruneSelection: (visibleReportIds) => { + const visibleIds = new Set(visibleReportIds); + set((state) => ({ + selectedReportIds: state.selectedReportIds.filter((id) => + visibleIds.has(id), + ), + })); + }, + }), +);