diff --git a/apps/code/src/renderer/components/ui/Button.tsx b/apps/code/src/renderer/components/ui/Button.tsx new file mode 100644 index 000000000..c4467267d --- /dev/null +++ b/apps/code/src/renderer/components/ui/Button.tsx @@ -0,0 +1,77 @@ +import { Tooltip } from "@components/ui/Tooltip"; +import { Flex, Button as RadixButton, Text } from "@radix-ui/themes"; +import { + type ComponentPropsWithoutRef, + forwardRef, + type ReactNode, +} from "react"; + +export type ButtonProps = ComponentPropsWithoutRef & { + /** Primary tooltip explaining what the button does. */ + tooltipContent?: ReactNode; + /** + * When non-null and the button is disabled, shown after "Disabled because" in the tooltip. + * Must be null when the action is allowed. + */ + disabledReason?: string | null; +}; + +function disabledBecauseLabel(detail: string): string { + const d = detail.trim().replace(/\.$/, ""); + return `Disabled because ${d}.`; +} + +function buildTooltipContent( + tooltipContent: ReactNode | undefined, + disabledReason: string | null | undefined, + disabled: boolean | undefined, +): ReactNode | undefined { + const reason = disabled ? disabledReason : null; + if (tooltipContent != null && reason) { + return ( + + + {tooltipContent} + + + {disabledBecauseLabel(reason)} + + + ); + } + if (reason) { + return disabledBecauseLabel(reason); + } + if (tooltipContent != null) { + return tooltipContent; + } + return undefined; +} + +export const Button = forwardRef( + function Button({ tooltipContent, disabledReason, disabled, ...props }, ref) { + const tip = buildTooltipContent( + tooltipContent, + disabledReason ?? null, + disabled, + ); + + const button = ; + + if (tip === undefined) { + return button; + } + + // Disabled buttons don't receive pointer events; span keeps the tooltip hover target. + const trigger = + disabled === true ? ( + {button} + ) : ( + button + ); + + return {trigger}; + }, +); + +Button.displayName = "Button"; diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index fb0a103e5..8a5188136 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -353,17 +353,43 @@ export function InboxSignalsTab() { direction="column" tabIndex={0} className="outline-none" + // Clicking a row/button/checkbox would normally move browser focus to that + // element, losing the container's focus and breaking arrow-key navigation. + // Intercept mousedown to redirect focus back to the container instead. + // Text fields are exempt so the search box can still receive focus normally. onMouseDownCapture={(e) => { const target = e.target as HTMLElement; - if (target.closest("[data-report-id]")) { + if ( + target.closest( + "input, textarea, select, [contenteditable='true']", + ) + ) { + return; + } + if ( + target.closest( + "[data-report-id], button, [role='checkbox']", + ) + ) { focusListPane(); } }} + // Same redirect for focus arriving via keyboard (Tab) — if focus lands + // inside a row element rather than on the container itself, pull it back up. onFocusCapture={(e) => { const target = e.target as HTMLElement; + if ( + target.closest( + "input, textarea, select, [contenteditable='true']", + ) + ) { + return; + } if ( target !== leftPaneRef.current && - target.closest("[data-report-id]") + target.closest( + "[data-report-id], button, [role='checkbox']", + ) ) { focusListPane(); } @@ -468,18 +494,21 @@ export function InboxSignalsTab() { display: "flex", alignItems: "center", justifyContent: "center", + pointerEvents: "none", background: "linear-gradient(to bottom, transparent 0%, var(--color-background) 30%)", }} > - {!hasSignalSources ? ( - setSourcesDialogOpen(true)} /> - ) : ( - setSourcesDialogOpen(true)} - enabledProducts={enabledProducts} - /> - )} + + {!hasSignalSources ? ( + setSourcesDialogOpen(true)} /> + ) : ( + setSourcesDialogOpen(true)} + enabledProducts={enabledProducts} + /> + )} + )} 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 34397f582..b0e9b0845 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx @@ -166,8 +166,8 @@ export function ReportListPane({ {reports.map((report, index) => ( onSelectReport(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 3832e4e27..a1227d0cb 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportListRow.tsx @@ -33,19 +33,6 @@ export function ReportListRow({ }, ); - 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 => { @@ -67,6 +54,14 @@ export function ReportListRow({ onToggleChecked(); }; + const rowBgClass = isSelected + ? "bg-gray-3" + : isChecked + ? "bg-gray-2" + : report.is_suggested_reviewer + ? "bg-blue-2" + : ""; + return ( - + { e.preventDefault(); @@ -129,16 +122,16 @@ export function ReportListRow({ /> - + - - {(report.source_products ?? []).length > 0 ? ( - (report.source_products ?? []).map((sp) => { + {(report.source_products ?? []).length > 0 && ( + + {(report.source_products ?? []).map((sp) => { const meta = SOURCE_PRODUCT_META[sp]; if (!meta) return null; const { Icon } = meta; @@ -147,16 +140,9 @@ export function ReportListRow({ ); - }) - ) : ( - - )} - + })} + + )} void; } function formatPauseRemaining(pausedUntil: string): string { @@ -64,6 +68,8 @@ function formatPauseRemaining(pausedUntil: string): string { return remainingHours > 0 ? `${days}d ${remainingHours}h` : `${days}d`; } +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, @@ -75,24 +81,20 @@ 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, - canSuppress, - canSnooze, - canDelete, - canReingest, + snoozeDisabledReason, + suppressDisabledReason, + deleteDisabledReason, + reingestDisabledReason, isSuppressing, isSnoozing, isDeleting, @@ -144,21 +146,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 ? ( @@ -195,24 +191,11 @@ export function SignalsToolbar({ - - - handleToggleSelectAll(checked === true) - } - aria-label="Select all visible reports" - /> -