From 1dc4703af6cffa4437be66ae029bbabbf9822466 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Mon, 25 May 2026 06:51:52 -0300 Subject: [PATCH 1/4] feat(code): add Board view to inbox alongside the existing list Adds a "View as" toggle to the inbox toolbar (List / Board). Board view groups inbox reports into a horizontal kanban by status (Ready, Needs input, Researching, Queued, Gathering, Failed), each lane sharing the existing `ReportCardContent` so badges, summaries, and selection match the list view. The choice is persisted via `inboxSignalsFilterStore.viewMode`. Infinite-scroll loading is preserved by parking the load-more sentinel at the bottom of the lane with the most reports. Generated-By: PostHog Code Task-Id: b4476476-5cb0-4ab1-8062-555eba7bfbda --- .../inbox/components/InboxSignalsTab.tsx | 56 ++- .../inbox/components/list/ReportBoardPane.tsx | 427 ++++++++++++++++++ .../inbox/components/list/SignalsToolbar.tsx | 59 ++- .../stores/inboxSignalsFilterStore.test.ts | 19 + .../inbox/stores/inboxSignalsFilterStore.ts | 8 + 5 files changed, 551 insertions(+), 18 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 726e6f177..7da5a5ff3 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -55,6 +55,7 @@ import { import { MultiSelectStack } from "./detail/MultiSelectStack"; import { ReportDetailPane } from "./detail/ReportDetailPane"; import { GitHubConnectionBanner } from "./list/GitHubConnectionBanner"; +import { ReportBoardPane } from "./list/ReportBoardPane"; import { ReportListPane } from "./list/ReportListPane"; import { SignalsToolbar } from "./list/SignalsToolbar"; @@ -75,6 +76,7 @@ export function InboxSignalsTab() { const seedSuggestedReviewerFilterWithCurrentUser = useInboxSignalsFilterStore( (s) => s.seedSuggestedReviewerFilterWithCurrentUser, ); + const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); // ── Current user (seeds reviewer filter on first inbox visit) ─────────── const authClient = useOptionalAuthenticatedClient(); @@ -762,23 +764,43 @@ export function InboxSignalsTab() { - + {viewMode === "board" ? ( + + ) : ( + + )} diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx new file mode 100644 index 000000000..f7555e1a9 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx @@ -0,0 +1,427 @@ +import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; +import { + inboxStatusAccentCss, + inboxStatusLabel, +} from "@features/inbox/utils/inboxSort"; +import { + ArrowsClockwiseIcon, + CircleNotchIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { Box, Button, Checkbox, Flex, Text } from "@radix-ui/themes"; +import type { SignalReport, SignalReportStatus } from "@shared/types"; +import { motion } from "framer-motion"; +import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; +import { useEffect, useMemo, useRef } from "react"; + +// Lanes shown on the board, in display order. Hidden statuses (`suppressed`, +// `deleted`) never appear here — they're already excluded from the inbox feed. +const BOARD_LANE_STATUSES: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", + "failed", +]; + +function groupReportsByStatus( + reports: SignalReport[], +): Map { + const grouped = new Map( + BOARD_LANE_STATUSES.map((status) => [status, []]), + ); + for (const report of reports) { + const bucket = grouped.get(report.status); + if (bucket) { + bucket.push(report); + } + } + return grouped; +} + +// ── LoadMoreTrigger ───────────────────────────────────────────────────────── +// Sits at the bottom of the longest lane so the board keeps loading as the +// user scrolls. + +function LoadMoreTrigger({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +}) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || !hasNextPage) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (!hasNextPage && !isFetchingNextPage) return null; + + return ( + + {isFetchingNextPage ? ( + + Loading more... + + ) : null} + + ); +} + +// ── BoardCard ─────────────────────────────────────────────────────────────── + +interface BoardCardProps { + report: SignalReport; + index: number; + isSelected: boolean; + showCheckbox: boolean; + onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; + onToggleChecked: () => void; +} + +function BoardCard({ + report, + index, + isSelected, + showCheckbox, + onClick, + onToggleChecked, +}: BoardCardProps) { + 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({ metaKey: e.metaKey, shiftKey: e.shiftKey }); + }; + + const selectedBgClass = isSelected ? "bg-gray-3" : "bg-(--color-panel-solid)"; + + return ( + { + e.preventDefault(); + }} + onClick={handleActivate} + onKeyDown={(e: KeyboardEvent) => { + if (isInteractiveTarget(e.target)) { + return; + } + if (e.key === "Enter") { + e.preventDefault(); + handleActivate(e); + } + }} + className={[ + "relative isolate w-full cursor-pointer overflow-hidden rounded-(--radius-2) border border-(--gray-5) p-2 text-left transition-colors", + "before:pointer-events-none before:absolute before:inset-0 before:z-1 before:bg-gray-12 before:opacity-0 hover:before:opacity-[0.05]", + selectedBgClass, + ].join(" ")} + > + + {showCheckbox ? ( + + { + e.preventDefault(); + }} + onClick={(e) => { + e.stopPropagation(); + }} + onCheckedChange={() => onToggleChecked()} + aria-label={ + isSelected + ? "Unselect report from bulk actions" + : "Select report for bulk actions" + } + /> + + ) : null} +
+ +
+
+
+ ); +} + +// ── BoardLane ─────────────────────────────────────────────────────────────── + +interface BoardLaneProps { + status: SignalReportStatus; + reports: SignalReport[]; + startIndex: number; + selectedIdSet: Set; + showCheckboxes: boolean; + onReportClick: ( + id: string, + event: { metaKey: boolean; shiftKey: boolean }, + ) => void; + onToggleReportSelection: (id: string) => void; + footer?: ReactNode; +} + +function BoardLane({ + status, + reports, + startIndex, + selectedIdSet, + showCheckboxes, + onReportClick, + onToggleReportSelection, + footer, +}: BoardLaneProps) { + const accent = inboxStatusAccentCss(status); + const label = inboxStatusLabel(status); + + return ( + + + + {label} + + {reports.length} + + + + {reports.length === 0 ? ( + + + No reports + + + ) : ( + reports.map((report, idx) => ( + onReportClick(report.id, e)} + onToggleChecked={() => onToggleReportSelection(report.id)} + /> + )) + )} + {footer} + + + ); +} + +// ── ReportBoardPane ───────────────────────────────────────────────────────── + +interface ReportBoardPaneProps { + reports: SignalReport[]; + allReports: SignalReport[]; + isLoading: boolean; + isFetching: boolean; + error: Error | null; + refetch: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasSignalSources: boolean; + searchQuery: string; + hasActiveFilters: boolean; + selectedReportIds: string[]; + onReportClick: ( + id: string, + event: { metaKey: boolean; shiftKey: boolean }, + ) => void; + onToggleReportSelection: (id: string) => void; +} + +export function ReportBoardPane({ + reports, + allReports, + isLoading, + isFetching, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + hasSignalSources, + searchQuery, + hasActiveFilters, + selectedReportIds = [], + onReportClick, + onToggleReportSelection, +}: ReportBoardPaneProps) { + const grouped = useMemo(() => groupReportsByStatus(reports), [reports]); + + // ── Loading skeleton ──────────────────────────────────────────────────── + if (isLoading && allReports.length === 0 && hasSignalSources) { + return ( + + {BOARD_LANE_STATUSES.map((status) => ( + + + + + + {Array.from({ length: 3 }).map((_, idx) => ( + + ))} + + + ))} + + ); + } + + // ── Error state ───────────────────────────────────────────────────────── + if (error) { + return ( + + + + + Could not load signals + + + + + ); + } + + // ── No search results ─────────────────────────────────────────────────── + if (reports.length === 0 && searchQuery.trim()) { + return ( + + + No matching reports + + + ); + } + + // ── No filter results ─────────────────────────────────────────────────── + if (reports.length === 0 && hasActiveFilters) { + return ( + + + No reports match current filters + + + ); + } + + const selectedIdSet = new Set(selectedReportIds); + const showCheckboxes = selectedReportIds.length > 1; + + // Place the "load more" sentinel at the bottom of the lane that has the + // most reports — that's the column the user is most likely scrolling. + const longestLaneStatus = BOARD_LANE_STATUSES.reduce( + (best, status) => + (grouped.get(status)?.length ?? 0) > (grouped.get(best)?.length ?? 0) + ? status + : best, + BOARD_LANE_STATUSES[0], + ); + + let runningIndex = 0; + + return ( + + + {BOARD_LANE_STATUSES.map((status) => { + const laneReports = grouped.get(status) ?? []; + const startIndex = runningIndex; + runningIndex += laneReports.length; + return ( + + ) : null + } + /> + ); + })} + + + ); +} 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 1304ea04d..b6ca5ece9 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -1,13 +1,18 @@ import { Button, type ButtonProps } from "@components/ui/Button"; import { Tooltip as ActionTooltip } from "@components/ui/Tooltip"; import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; -import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + type InboxViewMode, + useInboxSignalsFilterStore, +} from "@features/inbox/stores/inboxSignalsFilterStore"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { ArrowClockwiseIcon, DotsThree, EyeSlashIcon, GearSixIcon, + KanbanIcon, + ListBulletsIcon, MagnifyingGlass, PauseIcon, ThumbsDownIcon, @@ -36,6 +41,57 @@ import { useState } from "react"; import { FilterSortMenu } from "./FilterSortMenu"; import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; +function InboxViewModeToggle() { + const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); + const setViewMode = useInboxSignalsFilterStore((s) => s.setViewMode); + + const options: { + value: InboxViewMode; + label: string; + icon: ReactNode; + }[] = [ + { + value: "list", + label: "View as list", + icon: , + }, + { + value: "board", + label: "View as board", + icon: , + }, + ]; + + return ( + + {options.map((option) => { + const isActive = viewMode === option.value; + return ( + + + + ); + })} + + ); +} + interface SignalsToolbarProps { totalCount: number; filteredCount: number; @@ -532,6 +588,7 @@ export function SignalsToolbar({ {!hideFilters && ( + diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts index ff698fa17..cb56c715e 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts @@ -19,6 +19,7 @@ describe("inboxSignalsFilterStore", () => { sourceProductFilter: [], suggestedReviewerFilter: [], hasInitializedSuggestedReviewerFilter: false, + viewMode: "list", }); }); @@ -169,6 +170,24 @@ describe("inboxSignalsFilterStore", () => { expect(state.hasInitializedSuggestedReviewerFilter).toBe(true); }); + it("setViewMode switches between list and board", () => { + expect(useInboxSignalsFilterStore.getState().viewMode).toBe("list"); + + useInboxSignalsFilterStore.getState().setViewMode("board"); + expect(useInboxSignalsFilterStore.getState().viewMode).toBe("board"); + + useInboxSignalsFilterStore.getState().setViewMode("list"); + expect(useInboxSignalsFilterStore.getState().viewMode).toBe("list"); + }); + + it("persists viewMode", () => { + useInboxSignalsFilterStore.getState().setViewMode("board"); + const raw = localStorage.getItem("inbox-signals-filter-storage"); + expect(raw).toBeTruthy(); + const persisted = JSON.parse(raw as string); + expect(persisted.state.viewMode).toBe("board"); + }); + it("resetFilters preserves sort preferences", () => { useInboxSignalsFilterStore.getState().setSort("created_at", "asc"); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index 51338816d..2ea5277dd 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -12,6 +12,8 @@ type SignalSortField = Extract< type SignalSortDirection = "asc" | "desc"; +export type InboxViewMode = "list" | "board"; + export type SourceProduct = | "session_replay" | "error_tracking" @@ -42,6 +44,8 @@ interface InboxSignalsFilterState { suggestedReviewerFilter: string[]; /** Tracks whether we've seeded the reviewer filter with the current user once. Persisted so the seed only runs on first inbox visit. */ hasInitializedSuggestedReviewerFilter: boolean; + /** How the inbox list is rendered: a flat list or a kanban board grouped by status. */ + viewMode: InboxViewMode; } interface InboxSignalsFilterActions { @@ -60,6 +64,7 @@ interface InboxSignalsFilterActions { seedSuggestedReviewerFilterWithCurrentUser: (currentUserUuid: string) => void; /** Reset all filters when a deep link arrives so the linked report isn't hidden. */ resetFilters: () => void; + setViewMode: (viewMode: InboxViewMode) => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & @@ -75,6 +80,7 @@ export const useInboxSignalsFilterStore = create()( sourceProductFilter: [], suggestedReviewerFilter: [], hasInitializedSuggestedReviewerFilter: false, + viewMode: "list", setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setSearchQuery: (searchQuery) => set({ searchQuery }), setStatusFilter: (statusFilter) => set({ statusFilter }), @@ -124,6 +130,7 @@ export const useInboxSignalsFilterStore = create()( sourceProductFilter: [], suggestedReviewerFilter: [], }), + setViewMode: (viewMode) => set({ viewMode }), }), { name: "inbox-signals-filter-storage", @@ -135,6 +142,7 @@ export const useInboxSignalsFilterStore = create()( suggestedReviewerFilter: state.suggestedReviewerFilter, hasInitializedSuggestedReviewerFilter: state.hasInitializedSuggestedReviewerFilter, + viewMode: state.viewMode, }), }, ), From 256c04176859727a24ec1927b30c075c643a2d8c Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Mon, 25 May 2026 08:49:17 -0300 Subject: [PATCH 2/4] feat(code): add board view mode to inbox Adds a "View as" toggle to the inbox toolbar with a list (default) and board option. The board groups reports into kanban columns by status (Ready, Needs input, Researching, Failed, Queued, Gathering), respecting active status filters. Selecting a card opens the standard detail pane in a right-side panel. The view mode is persisted in the filter store. Generated-By: PostHog Code Task-Id: bb7bd45f-d16b-4cca-a8bd-00856f6c5004 --- .../inbox/components/InboxSignalsTab.tsx | 307 +++++++------ .../inbox/components/board/InboxBoardView.tsx | 389 ++++++++++++++++ .../inbox/components/list/ReportBoardPane.tsx | 427 ------------------ .../inbox/components/list/SignalsToolbar.tsx | 61 +-- .../inbox/components/list/ViewModeToggle.tsx | 65 +++ .../stores/inboxSignalsFilterStore.test.ts | 19 - .../inbox/stores/inboxSignalsFilterStore.ts | 3 +- 7 files changed, 625 insertions(+), 646 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx delete mode 100644 apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx create mode 100644 apps/code/src/renderer/features/inbox/components/list/ViewModeToggle.tsx diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 7da5a5ff3..5a3d483af 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -1,5 +1,6 @@ import { useOptionalAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useCurrentUser } from "@features/auth/hooks/authQueries"; +import { InboxBoardView } from "@features/inbox/components/board/InboxBoardView"; import { SelectReportPane, SkeletonBackdrop, @@ -55,7 +56,6 @@ import { import { MultiSelectStack } from "./detail/MultiSelectStack"; import { ReportDetailPane } from "./detail/ReportDetailPane"; import { GitHubConnectionBanner } from "./list/GitHubConnectionBanner"; -import { ReportBoardPane } from "./list/ReportBoardPane"; import { ReportListPane } from "./list/ReportListPane"; import { SignalsToolbar } from "./list/SignalsToolbar"; @@ -67,6 +67,7 @@ export function InboxSignalsTab() { const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); const searchQuery = useInboxSignalsFilterStore((s) => s.searchQuery); const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); + const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); const sourceProductFilter = useInboxSignalsFilterStore( (s) => s.sourceProductFilter, ); @@ -76,7 +77,6 @@ export function InboxSignalsTab() { const seedSuggestedReviewerFilterWithCurrentUser = useInboxSignalsFilterStore( (s) => s.seedSuggestedReviewerFilterWithCurrentUser, ); - const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); // ── Current user (seeds reviewer filter on first inbox visit) ─────────── const authClient = useOptionalAuthenticatedClient(); @@ -683,106 +683,158 @@ export function InboxSignalsTab() { // ── Render ────────────────────────────────────────────────────────────── + const detailPaneContent = + selectedReports.length > 1 ? ( + + ) : selectedReport ? ( + + ) : selectedDiscoveredTask ? ( + + ) : ( + + ); + + const hasDetailSelection = + selectedReports.length > 1 || + selectedReport != null || + selectedDiscoveredTask != null; + + const toolbar = ( + setSourcesDialogOpen(true)} + onOpenDismissDialog={openDismissDialogFromToolbar} + isDismissMutationPending={dismissMutationPending} + onReportAction={tracker.signalAction} + /> + ); + return ( <> {showTwoPaneLayout ? ( - - {/* ── Left pane: report list ───────────────────────────────── */} - - + + {toolbar} + + + + + + {hasDetailSelection ? ( + + {detailPaneContent} + + ) : null} + + + ) : ( + + {/* ── Left pane: report list ───────────────────────────────── */} + - { - const target = e.target as HTMLElement; - if ( - target.closest( - "input, textarea, select, [contenteditable='true']", - ) - ) { - return; - } - if (target.closest("[data-report-id], button")) { - 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], button") - ) { - focusListPane(); - } - }} + - { + const target = e.target as HTMLElement; + if ( + target.closest( + "input, textarea, select, [contenteditable='true']", + ) + ) { + return; + } + if (target.closest("[data-report-id], button")) { + 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], button") + ) { + focusListPane(); + } + }} > - setSourcesDialogOpen(true)} - onOpenDismissDialog={openDismissDialogFromToolbar} - isDismissMutationPending={dismissMutationPending} - onReportAction={tracker.signalAction} + + {toolbar} + + - - - {viewMode === "board" ? ( - - ) : ( - )} - - - - + + - {/* Resize handle */} - - + - {/* ── Right pane: detail ───────────────────────────────── */} - - {selectedReports.length > 1 ? ( - - ) : selectedReport ? ( - - ) : selectedDiscoveredTask ? ( - - ) : ( - - )} + + + {/* ── Right pane: detail ───────────────────────────────── */} + + {detailPaneContent} + - + ) ) : ( /* ── Full-width empty state with skeleton backdrop ──────── */ diff --git a/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx new file mode 100644 index 000000000..624d53d1c --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx @@ -0,0 +1,389 @@ +import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + inboxStatusAccentCss, + inboxStatusLabel, +} from "@features/inbox/utils/inboxSort"; +import { + ArrowsClockwiseIcon, + CircleNotchIcon, + FileTextIcon, + WarningIcon, +} from "@phosphor-icons/react"; +import { Box, Button, Flex, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; +import type { SignalReport, SignalReportStatus } from "@shared/types"; +import { motion } from "framer-motion"; +import { + type KeyboardEvent, + type MouseEvent, + useEffect, + useMemo, + useRef, +} from "react"; + +const COLUMN_ORDER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", +]; + +function isInteractiveTarget(target: EventTarget | null): boolean { + return ( + target instanceof HTMLElement && + !!target.closest("a, button, input, select, textarea, [role='checkbox']") + ); +} + +function SourceProductIcon({ sourceProducts }: { sourceProducts?: string[] }) { + const firstProduct = sourceProducts?.[0]; + const meta = firstProduct ? SOURCE_PRODUCT_META[firstProduct] : undefined; + + if (!meta) { + return ( + + + + ); + } + + return ( + + + + + + ); +} + +interface InboxBoardCardProps { + report: SignalReport; + isSelected: boolean; + index: number; + onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; +} + +function InboxBoardCard({ + report, + isSelected, + index, + onClick, +}: InboxBoardCardProps) { + const handleActivate = (e: MouseEvent | KeyboardEvent) => { + if (isInteractiveTarget(e.target)) return; + onClick({ metaKey: e.metaKey, shiftKey: e.shiftKey }); + }; + + return ( + { + e.preventDefault(); + }} + onClick={handleActivate} + onKeyDown={(e: KeyboardEvent) => { + if (isInteractiveTarget(e.target)) return; + if (e.key === "Enter") { + e.preventDefault(); + handleActivate(e); + } + }} + className={[ + "relative isolate cursor-pointer rounded-(--radius-2) border bg-(--color-panel-solid) p-2 text-left transition-colors", + "before:pointer-events-none before:absolute before:inset-0 before:rounded-(--radius-2) before:bg-gray-12 before:opacity-0 hover:before:opacity-[0.05]", + isSelected + ? "border-(--accent-8) ring-(--accent-8) ring-1" + : "border-(--gray-5)", + ].join(" ")} + > + + + + +
+ +
+
+
+ ); +} + +interface BoardLoadMoreTriggerProps { + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; +} + +function BoardLoadMoreTrigger({ + hasNextPage, + isFetchingNextPage, + fetchNextPage, +}: BoardLoadMoreTriggerProps) { + const ref = useRef(null); + + useEffect(() => { + const el = ref.current; + if (!el || !hasNextPage) return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting && !isFetchingNextPage) { + fetchNextPage(); + } + }, + { threshold: 0 }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + if (!hasNextPage && !isFetchingNextPage) return null; + + return ( + + {isFetchingNextPage ? ( + + Loading more... + + ) : null} + + ); +} + +interface InboxBoardColumnProps { + status: SignalReportStatus; + reports: SignalReport[]; + selectedIdSet: Set; + onReportClick: ( + id: string, + event: { metaKey: boolean; shiftKey: boolean }, + ) => void; +} + +function InboxBoardColumn({ + status, + reports, + selectedIdSet, + onReportClick, +}: InboxBoardColumnProps) { + const accent = inboxStatusAccentCss(status); + const label = inboxStatusLabel(status); + + return ( + + + + + + {label} + + + + {reports.length} + + + + + + {reports.length === 0 ? ( + + + No items + + + ) : ( + reports.map((report, index) => ( + onReportClick(report.id, e)} + /> + )) + )} + + + + ); +} + +interface InboxBoardViewProps { + reports: SignalReport[]; + allReports: SignalReport[]; + isLoading: boolean; + isFetching: boolean; + error: Error | null; + refetch: () => void; + hasNextPage: boolean; + isFetchingNextPage: boolean; + fetchNextPage: () => void; + hasSignalSources: boolean; + searchQuery: string; + hasActiveFilters: boolean; + selectedReportIds: string[]; + onReportClick: ( + id: string, + event: { metaKey: boolean; shiftKey: boolean }, + ) => void; +} + +export function InboxBoardView({ + reports, + allReports, + isLoading, + isFetching, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + hasSignalSources, + searchQuery, + hasActiveFilters, + selectedReportIds, + onReportClick, +}: InboxBoardViewProps) { + const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); + + const visibleStatuses = useMemo(() => { + const allowed = new Set(statusFilter); + return COLUMN_ORDER.filter((status) => allowed.has(status)); + }, [statusFilter]); + + const reportsByStatus = useMemo(() => { + const map = new Map(); + for (const status of visibleStatuses) { + map.set(status, []); + } + for (const report of reports) { + const bucket = map.get(report.status); + if (bucket) { + bucket.push(report); + } + } + return map; + }, [reports, visibleStatuses]); + + const selectedIdSet = useMemo( + () => new Set(selectedReportIds), + [selectedReportIds], + ); + + if (isLoading && allReports.length === 0 && hasSignalSources) { + return ( + + {visibleStatuses.map((status) => ( + + ))} + + ); + } + + if (error) { + return ( + + + + + Could not load signals + + + + + ); + } + + if (reports.length === 0 && searchQuery.trim()) { + return ( + + + No matching reports + + + ); + } + + if (reports.length === 0 && hasActiveFilters) { + return ( + + + No reports match current filters + + + ); + } + + return ( + + + + {visibleStatuses.map((status) => ( + + ))} + + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx deleted file mode 100644 index f7555e1a9..000000000 --- a/apps/code/src/renderer/features/inbox/components/list/ReportBoardPane.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { ReportCardContent } from "@features/inbox/components/utils/ReportCardContent"; -import { - inboxStatusAccentCss, - inboxStatusLabel, -} from "@features/inbox/utils/inboxSort"; -import { - ArrowsClockwiseIcon, - CircleNotchIcon, - WarningIcon, -} from "@phosphor-icons/react"; -import { Box, Button, Checkbox, Flex, Text } from "@radix-ui/themes"; -import type { SignalReport, SignalReportStatus } from "@shared/types"; -import { motion } from "framer-motion"; -import type { KeyboardEvent, MouseEvent, ReactNode } from "react"; -import { useEffect, useMemo, useRef } from "react"; - -// Lanes shown on the board, in display order. Hidden statuses (`suppressed`, -// `deleted`) never appear here — they're already excluded from the inbox feed. -const BOARD_LANE_STATUSES: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "candidate", - "potential", - "failed", -]; - -function groupReportsByStatus( - reports: SignalReport[], -): Map { - const grouped = new Map( - BOARD_LANE_STATUSES.map((status) => [status, []]), - ); - for (const report of reports) { - const bucket = grouped.get(report.status); - if (bucket) { - bucket.push(report); - } - } - return grouped; -} - -// ── LoadMoreTrigger ───────────────────────────────────────────────────────── -// Sits at the bottom of the longest lane so the board keeps loading as the -// user scrolls. - -function LoadMoreTrigger({ - hasNextPage, - isFetchingNextPage, - fetchNextPage, -}: { - hasNextPage: boolean; - isFetchingNextPage: boolean; - fetchNextPage: () => void; -}) { - const ref = useRef(null); - - useEffect(() => { - const el = ref.current; - if (!el || !hasNextPage) return; - - const observer = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !isFetchingNextPage) { - fetchNextPage(); - } - }, - { threshold: 0 }, - ); - observer.observe(el); - return () => observer.disconnect(); - }, [hasNextPage, isFetchingNextPage, fetchNextPage]); - - if (!hasNextPage && !isFetchingNextPage) return null; - - return ( - - {isFetchingNextPage ? ( - - Loading more... - - ) : null} - - ); -} - -// ── BoardCard ─────────────────────────────────────────────────────────────── - -interface BoardCardProps { - report: SignalReport; - index: number; - isSelected: boolean; - showCheckbox: boolean; - onClick: (event: { metaKey: boolean; shiftKey: boolean }) => void; - onToggleChecked: () => void; -} - -function BoardCard({ - report, - index, - isSelected, - showCheckbox, - onClick, - onToggleChecked, -}: BoardCardProps) { - 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({ metaKey: e.metaKey, shiftKey: e.shiftKey }); - }; - - const selectedBgClass = isSelected ? "bg-gray-3" : "bg-(--color-panel-solid)"; - - return ( - { - e.preventDefault(); - }} - onClick={handleActivate} - onKeyDown={(e: KeyboardEvent) => { - if (isInteractiveTarget(e.target)) { - return; - } - if (e.key === "Enter") { - e.preventDefault(); - handleActivate(e); - } - }} - className={[ - "relative isolate w-full cursor-pointer overflow-hidden rounded-(--radius-2) border border-(--gray-5) p-2 text-left transition-colors", - "before:pointer-events-none before:absolute before:inset-0 before:z-1 before:bg-gray-12 before:opacity-0 hover:before:opacity-[0.05]", - selectedBgClass, - ].join(" ")} - > - - {showCheckbox ? ( - - { - e.preventDefault(); - }} - onClick={(e) => { - e.stopPropagation(); - }} - onCheckedChange={() => onToggleChecked()} - aria-label={ - isSelected - ? "Unselect report from bulk actions" - : "Select report for bulk actions" - } - /> - - ) : null} -
- -
-
-
- ); -} - -// ── BoardLane ─────────────────────────────────────────────────────────────── - -interface BoardLaneProps { - status: SignalReportStatus; - reports: SignalReport[]; - startIndex: number; - selectedIdSet: Set; - showCheckboxes: boolean; - onReportClick: ( - id: string, - event: { metaKey: boolean; shiftKey: boolean }, - ) => void; - onToggleReportSelection: (id: string) => void; - footer?: ReactNode; -} - -function BoardLane({ - status, - reports, - startIndex, - selectedIdSet, - showCheckboxes, - onReportClick, - onToggleReportSelection, - footer, -}: BoardLaneProps) { - const accent = inboxStatusAccentCss(status); - const label = inboxStatusLabel(status); - - return ( - - - - {label} - - {reports.length} - - - - {reports.length === 0 ? ( - - - No reports - - - ) : ( - reports.map((report, idx) => ( - onReportClick(report.id, e)} - onToggleChecked={() => onToggleReportSelection(report.id)} - /> - )) - )} - {footer} - - - ); -} - -// ── ReportBoardPane ───────────────────────────────────────────────────────── - -interface ReportBoardPaneProps { - reports: SignalReport[]; - allReports: SignalReport[]; - isLoading: boolean; - isFetching: boolean; - error: Error | null; - refetch: () => void; - hasNextPage: boolean; - isFetchingNextPage: boolean; - fetchNextPage: () => void; - hasSignalSources: boolean; - searchQuery: string; - hasActiveFilters: boolean; - selectedReportIds: string[]; - onReportClick: ( - id: string, - event: { metaKey: boolean; shiftKey: boolean }, - ) => void; - onToggleReportSelection: (id: string) => void; -} - -export function ReportBoardPane({ - reports, - allReports, - isLoading, - isFetching, - error, - refetch, - hasNextPage, - isFetchingNextPage, - fetchNextPage, - hasSignalSources, - searchQuery, - hasActiveFilters, - selectedReportIds = [], - onReportClick, - onToggleReportSelection, -}: ReportBoardPaneProps) { - const grouped = useMemo(() => groupReportsByStatus(reports), [reports]); - - // ── Loading skeleton ──────────────────────────────────────────────────── - if (isLoading && allReports.length === 0 && hasSignalSources) { - return ( - - {BOARD_LANE_STATUSES.map((status) => ( - - - - - - {Array.from({ length: 3 }).map((_, idx) => ( - - ))} - - - ))} - - ); - } - - // ── Error state ───────────────────────────────────────────────────────── - if (error) { - return ( - - - - - Could not load signals - - - - - ); - } - - // ── No search results ─────────────────────────────────────────────────── - if (reports.length === 0 && searchQuery.trim()) { - return ( - - - No matching reports - - - ); - } - - // ── No filter results ─────────────────────────────────────────────────── - if (reports.length === 0 && hasActiveFilters) { - return ( - - - No reports match current filters - - - ); - } - - const selectedIdSet = new Set(selectedReportIds); - const showCheckboxes = selectedReportIds.length > 1; - - // Place the "load more" sentinel at the bottom of the lane that has the - // most reports — that's the column the user is most likely scrolling. - const longestLaneStatus = BOARD_LANE_STATUSES.reduce( - (best, status) => - (grouped.get(status)?.length ?? 0) > (grouped.get(best)?.length ?? 0) - ? status - : best, - BOARD_LANE_STATUSES[0], - ); - - let runningIndex = 0; - - return ( - - - {BOARD_LANE_STATUSES.map((status) => { - const laneReports = grouped.get(status) ?? []; - const startIndex = runningIndex; - runningIndex += laneReports.length; - return ( - - ) : null - } - /> - ); - })} - - - ); -} 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 b6ca5ece9..fbfc4ea03 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -1,18 +1,13 @@ import { Button, type ButtonProps } from "@components/ui/Button"; import { Tooltip as ActionTooltip } from "@components/ui/Tooltip"; import { useInboxBulkActions } from "@features/inbox/hooks/useInboxBulkActions"; -import { - type InboxViewMode, - useInboxSignalsFilterStore, -} from "@features/inbox/stores/inboxSignalsFilterStore"; +import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { ArrowClockwiseIcon, DotsThree, EyeSlashIcon, GearSixIcon, - KanbanIcon, - ListBulletsIcon, MagnifyingGlass, PauseIcon, ThumbsDownIcon, @@ -40,57 +35,7 @@ import type { ReactNode } from "react"; import { useState } from "react"; import { FilterSortMenu } from "./FilterSortMenu"; import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; - -function InboxViewModeToggle() { - const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); - const setViewMode = useInboxSignalsFilterStore((s) => s.setViewMode); - - const options: { - value: InboxViewMode; - label: string; - icon: ReactNode; - }[] = [ - { - value: "list", - label: "View as list", - icon: , - }, - { - value: "board", - label: "View as board", - icon: , - }, - ]; - - return ( - - {options.map((option) => { - const isActive = viewMode === option.value; - return ( - - - - ); - })} - - ); -} +import { ViewModeToggle } from "./ViewModeToggle"; interface SignalsToolbarProps { totalCount: number; @@ -588,9 +533,9 @@ export function SignalsToolbar({ {!hideFilters && ( - + )} diff --git a/apps/code/src/renderer/features/inbox/components/list/ViewModeToggle.tsx b/apps/code/src/renderer/features/inbox/components/list/ViewModeToggle.tsx new file mode 100644 index 000000000..3f9fd2de3 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/list/ViewModeToggle.tsx @@ -0,0 +1,65 @@ +import { + type InboxViewMode, + useInboxSignalsFilterStore, +} from "@features/inbox/stores/inboxSignalsFilterStore"; +import { KanbanIcon, ListBulletsIcon } from "@phosphor-icons/react"; +import { Tooltip } from "@radix-ui/themes"; +import type { ReactNode } from "react"; + +interface OptionButtonProps { + mode: InboxViewMode; + active: boolean; + label: string; + icon: ReactNode; + onSelect: (mode: InboxViewMode) => void; +} + +function OptionButton({ + mode, + active, + label, + icon, + onSelect, +}: OptionButtonProps) { + return ( + + + + ); +} + +export function ViewModeToggle() { + const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); + const setViewMode = useInboxSignalsFilterStore((s) => s.setViewMode); + + return ( +
+ } + onSelect={setViewMode} + /> + } + onSelect={setViewMode} + /> +
+ ); +} diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts index cb56c715e..ff698fa17 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.test.ts @@ -19,7 +19,6 @@ describe("inboxSignalsFilterStore", () => { sourceProductFilter: [], suggestedReviewerFilter: [], hasInitializedSuggestedReviewerFilter: false, - viewMode: "list", }); }); @@ -170,24 +169,6 @@ describe("inboxSignalsFilterStore", () => { expect(state.hasInitializedSuggestedReviewerFilter).toBe(true); }); - it("setViewMode switches between list and board", () => { - expect(useInboxSignalsFilterStore.getState().viewMode).toBe("list"); - - useInboxSignalsFilterStore.getState().setViewMode("board"); - expect(useInboxSignalsFilterStore.getState().viewMode).toBe("board"); - - useInboxSignalsFilterStore.getState().setViewMode("list"); - expect(useInboxSignalsFilterStore.getState().viewMode).toBe("list"); - }); - - it("persists viewMode", () => { - useInboxSignalsFilterStore.getState().setViewMode("board"); - const raw = localStorage.getItem("inbox-signals-filter-storage"); - expect(raw).toBeTruthy(); - const persisted = JSON.parse(raw as string); - expect(persisted.state.viewMode).toBe("board"); - }); - it("resetFilters preserves sort preferences", () => { useInboxSignalsFilterStore.getState().setSort("created_at", "asc"); diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index 2ea5277dd..b11c0d2b4 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -44,7 +44,6 @@ interface InboxSignalsFilterState { suggestedReviewerFilter: string[]; /** Tracks whether we've seeded the reviewer filter with the current user once. Persisted so the seed only runs on first inbox visit. */ hasInitializedSuggestedReviewerFilter: boolean; - /** How the inbox list is rendered: a flat list or a kanban board grouped by status. */ viewMode: InboxViewMode; } @@ -64,7 +63,7 @@ interface InboxSignalsFilterActions { seedSuggestedReviewerFilterWithCurrentUser: (currentUserUuid: string) => void; /** Reset all filters when a deep link arrives so the linked report isn't hidden. */ resetFilters: () => void; - setViewMode: (viewMode: InboxViewMode) => void; + setViewMode: (mode: InboxViewMode) => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & From ed5817f51238abf078b9ec5962b0f91d0b93a296 Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Mon, 25 May 2026 12:41:17 -0300 Subject: [PATCH 3/4] fix(code): scrollable columns + resizable detail pane in inbox board - Each board column now has its own vertical overflow, so it scrolls independently instead of the whole board overflowing the page. - Replaces the nested Radix ScrollArea (which doesn't propagate height through a horizontally-scrolling parent) with plain CSS overflow + a min-h-0 flex chain. - Detail pane in board mode is now 560px by default (was 480px, which squeezed the title in the header) and resizable from its left edge, persisted to its own store so it doesn't fight the list-mode sidebar width. - Infinite-scroll trigger moves to the bottom of the longest column so it actually enters the viewport when scrolling. Generated-By: PostHog Code Task-Id: bb7bd45f-d16b-4cca-a8bd-00856f6c5004 --- .../inbox/components/InboxSignalsTab.tsx | 60 +++++++++++- .../inbox/components/board/InboxBoardView.tsx | 93 +++++++++++-------- .../stores/inboxSignalsBoardDetailStore.ts | 7 ++ 3 files changed, 119 insertions(+), 41 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/stores/inboxSignalsBoardDetailStore.ts diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index 5a3d483af..7dd1e379d 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -22,6 +22,7 @@ import { } from "@features/inbox/hooks/useInboxReports"; import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; import { useInboxReportSelectionStore } from "@features/inbox/stores/inboxReportSelectionStore"; +import { useInboxSignalsBoardDetailStore } from "@features/inbox/stores/inboxSignalsBoardDetailStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; @@ -446,6 +447,56 @@ export function InboxSignalsTab() { }; }, [sidebarIsResizing, setSidebarWidth, setSidebarIsResizing]); + // ── Board-mode detail pane resize ────────────────────────────────────── + const boardDetailWidth = useInboxSignalsBoardDetailStore((s) => s.width); + const boardDetailIsResizing = useInboxSignalsBoardDetailStore( + (s) => s.isResizing, + ); + const setBoardDetailWidth = useInboxSignalsBoardDetailStore( + (s) => s.setWidth, + ); + const setBoardDetailIsResizing = useInboxSignalsBoardDetailStore( + (s) => s.setIsResizing, + ); + const boardContainerRef = useRef(null); + + const handleBoardDetailResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setBoardDetailIsResizing(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [setBoardDetailIsResizing], + ); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!boardDetailIsResizing || !boardContainerRef.current) return; + const rect = boardContainerRef.current.getBoundingClientRect(); + const containerWidth = rect.width; + const maxWidth = Math.max(420, containerWidth * 0.7); + const newWidth = Math.max( + 420, + Math.min(maxWidth, rect.right - e.clientX), + ); + setBoardDetailWidth(newWidth); + }; + const handleMouseUp = () => { + if (boardDetailIsResizing) { + setBoardDetailIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [boardDetailIsResizing, setBoardDetailWidth, setBoardDetailIsResizing]); + // ── Discovered-task suggestions (rendered inline at top of list) ─────── const discoveredTasks = useSetupStore((s) => s.discoveredTasks); const hasDiscoveredTasks = discoveredTasks.length > 0; @@ -743,7 +794,7 @@ export function InboxSignalsTab() { {toolbar} - + + {detailPaneContent} ) : null} diff --git a/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx index 624d53d1c..6291f6bdf 100644 --- a/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx +++ b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx @@ -11,7 +11,7 @@ import { FileTextIcon, WarningIcon, } from "@phosphor-icons/react"; -import { Box, Button, Flex, ScrollArea, Text, Tooltip } from "@radix-ui/themes"; +import { Box, Button, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReport, SignalReportStatus } from "@shared/types"; import { motion } from "framer-motion"; import { @@ -156,10 +156,10 @@ function BoardLoadMoreTrigger({ if (!hasNextPage && !isFetchingNextPage) return null; return ( - + {isFetchingNextPage ? ( - - Loading more... + + Loading more… ) : null} @@ -174,6 +174,7 @@ interface InboxBoardColumnProps { id: string, event: { metaKey: boolean; shiftKey: boolean }, ) => void; + loadMoreTrigger: React.ReactNode | null; } function InboxBoardColumn({ @@ -181,6 +182,7 @@ function InboxBoardColumn({ reports, selectedIdSet, onReportClick, + loadMoreTrigger, }: InboxBoardColumnProps) { const accent = inboxStatusAccentCss(status); const label = inboxStatusLabel(status); @@ -188,7 +190,7 @@ function InboxBoardColumn({ return ( - +
{reports.length === 0 ? ( )) )} + {loadMoreTrigger} - +
); } @@ -301,16 +304,30 @@ export function InboxBoardView({ [selectedReportIds], ); + const longestColumnStatus = useMemo(() => { + let best: SignalReportStatus | null = null; + let bestLen = -1; + for (const [status, list] of reportsByStatus) { + if (list.length > bestLen) { + best = status; + bestLen = list.length; + } + } + return best; + }, [reportsByStatus]); + if (isLoading && allReports.length === 0 && hasSignalSources) { return ( - - {visibleStatuses.map((status) => ( - - ))} - +
+ + {visibleStatuses.map((status) => ( + + ))} + +
); } @@ -361,29 +378,27 @@ export function InboxBoardView({ } return ( - - - - {visibleStatuses.map((status) => ( - - ))} - - - - +
+ + {visibleStatuses.map((status) => ( + + ) : null + } + /> + ))} + +
); } diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsBoardDetailStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsBoardDetailStore.ts new file mode 100644 index 000000000..bd01434cd --- /dev/null +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsBoardDetailStore.ts @@ -0,0 +1,7 @@ +import { createSidebarStore } from "@stores/createSidebarStore"; + +export const useInboxSignalsBoardDetailStore = createSidebarStore({ + name: "inbox-signals-board-detail-storage", + defaultWidth: 560, + defaultOpen: true, +}); From 1565bbcf168a602b590fa4a75525e6a4b9a1f9af Mon Sep 17 00:00:00 2001 From: Fernando Gomes Date: Mon, 25 May 2026 12:55:28 -0300 Subject: [PATCH 4/4] feat(code): group inbox board by actionability or priority MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grouping by `status` is almost useless because nearly all reports sit in `ready` (the pipeline statuses cover the minority of items still being researched), so the board collapses into a single column. Adds a `Group by` menu next to the View toggle (only visible in board mode) with three options — Actionability (default), Priority, Status — and an `inboxBoardGrouping` helper that maps a report to a column id for each option. Actionability shows Actionable / Needs input / Not actionable / In pipeline; Priority shows P0…P4 plus Unprioritized; Status keeps the previous behaviour. Persisted in the existing inbox filter store as `boardGroupBy`. Generated-By: PostHog Code Task-Id: bb7bd45f-d16b-4cca-a8bd-00856f6c5004 --- .../inbox/components/board/InboxBoardView.tsx | 73 +++++----- .../components/list/BoardGroupByMenu.tsx | 47 +++++++ .../inbox/components/list/SignalsToolbar.tsx | 2 + .../inbox/stores/inboxSignalsFilterStore.ts | 7 + .../inbox/utils/inboxBoardGrouping.ts | 129 ++++++++++++++++++ 5 files changed, 217 insertions(+), 41 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/list/BoardGroupByMenu.tsx create mode 100644 apps/code/src/renderer/features/inbox/utils/inboxBoardGrouping.ts diff --git a/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx index 6291f6bdf..c377b7428 100644 --- a/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx +++ b/apps/code/src/renderer/features/inbox/components/board/InboxBoardView.tsx @@ -2,9 +2,10 @@ import { ReportCardContent } from "@features/inbox/components/utils/ReportCardCo import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { - inboxStatusAccentCss, - inboxStatusLabel, -} from "@features/inbox/utils/inboxSort"; + type BoardColumnDef, + getBoardColumns, + getReportColumnId, +} from "@features/inbox/utils/inboxBoardGrouping"; import { ArrowsClockwiseIcon, CircleNotchIcon, @@ -22,15 +23,6 @@ import { useRef, } from "react"; -const COLUMN_ORDER: SignalReportStatus[] = [ - "ready", - "pending_input", - "in_progress", - "failed", - "candidate", - "potential", -]; - function isInteractiveTarget(target: EventTarget | null): boolean { return ( target instanceof HTMLElement && @@ -167,7 +159,7 @@ function BoardLoadMoreTrigger({ } interface InboxBoardColumnProps { - status: SignalReportStatus; + column: BoardColumnDef; reports: SignalReport[]; selectedIdSet: Set; onReportClick: ( @@ -178,14 +170,13 @@ interface InboxBoardColumnProps { } function InboxBoardColumn({ - status, + column, reports, selectedIdSet, onReportClick, loadMoreTrigger, }: InboxBoardColumnProps) { - const accent = inboxStatusAccentCss(status); - const label = inboxStatusLabel(status); + const { accent, label } = column; return ( s.statusFilter); + const groupBy = useInboxSignalsFilterStore((s) => s.boardGroupBy); - const visibleStatuses = useMemo(() => { - const allowed = new Set(statusFilter); - return COLUMN_ORDER.filter((status) => allowed.has(status)); - }, [statusFilter]); + const columns = useMemo( + () => getBoardColumns(groupBy, new Set(statusFilter)), + [groupBy, statusFilter], + ); - const reportsByStatus = useMemo(() => { - const map = new Map(); - for (const status of visibleStatuses) { - map.set(status, []); + const reportsByColumn = useMemo(() => { + const map = new Map(); + for (const column of columns) { + map.set(column.id, []); } for (const report of reports) { - const bucket = map.get(report.status); - if (bucket) { - bucket.push(report); - } + const columnId = getReportColumnId(report, groupBy); + const bucket = map.get(columnId); + if (bucket) bucket.push(report); } return map; - }, [reports, visibleStatuses]); + }, [reports, columns, groupBy]); const selectedIdSet = useMemo( () => new Set(selectedReportIds), [selectedReportIds], ); - const longestColumnStatus = useMemo(() => { - let best: SignalReportStatus | null = null; + const longestColumnId = useMemo(() => { + let best: string | null = null; let bestLen = -1; - for (const [status, list] of reportsByStatus) { + for (const [id, list] of reportsByColumn) { if (list.length > bestLen) { - best = status; + best = id; bestLen = list.length; } } return best; - }, [reportsByStatus]); + }, [reportsByColumn]); if (isLoading && allReports.length === 0 && hasSignalSources) { return (
- {visibleStatuses.map((status) => ( + {columns.map((column) => ( ))} @@ -380,15 +371,15 @@ export function InboxBoardView({ return (
- {visibleStatuses.map((status) => ( + {columns.map((column) => ( s.boardGroupBy); + const setGroupBy = useInboxSignalsFilterStore((s) => s.setBoardGroupBy); + const viewMode = useInboxSignalsFilterStore((s) => s.viewMode); + + if (viewMode !== "board") return null; + + return ( + + + + + + Group by + {OPTIONS.map((option) => ( + setGroupBy(option)} + > + {boardGroupByLabel(option)} + + ))} + + + ); +} 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 fbfc4ea03..ed8962a4f 100644 --- a/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -33,6 +33,7 @@ import type { SignalReport } from "@shared/types"; import type { InboxReportActionProperties } from "@shared/types/analytics"; import type { ReactNode } from "react"; import { useState } from "react"; +import { BoardGroupByMenu } from "./BoardGroupByMenu"; import { FilterSortMenu } from "./FilterSortMenu"; import { SuggestedReviewerFilterMenu } from "./SuggestedReviewerFilterMenu"; import { ViewModeToggle } from "./ViewModeToggle"; @@ -533,6 +534,7 @@ export function SignalsToolbar({ {!hideFilters && ( + diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index b11c0d2b4..ddad11d4f 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -14,6 +14,8 @@ type SignalSortDirection = "asc" | "desc"; export type InboxViewMode = "list" | "board"; +export type InboxBoardGroupBy = "status" | "actionability" | "priority"; + export type SourceProduct = | "session_replay" | "error_tracking" @@ -45,6 +47,7 @@ interface InboxSignalsFilterState { /** Tracks whether we've seeded the reviewer filter with the current user once. Persisted so the seed only runs on first inbox visit. */ hasInitializedSuggestedReviewerFilter: boolean; viewMode: InboxViewMode; + boardGroupBy: InboxBoardGroupBy; } interface InboxSignalsFilterActions { @@ -64,6 +67,7 @@ interface InboxSignalsFilterActions { /** Reset all filters when a deep link arrives so the linked report isn't hidden. */ resetFilters: () => void; setViewMode: (mode: InboxViewMode) => void; + setBoardGroupBy: (groupBy: InboxBoardGroupBy) => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & @@ -80,6 +84,7 @@ export const useInboxSignalsFilterStore = create()( suggestedReviewerFilter: [], hasInitializedSuggestedReviewerFilter: false, viewMode: "list", + boardGroupBy: "actionability", setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setSearchQuery: (searchQuery) => set({ searchQuery }), setStatusFilter: (statusFilter) => set({ statusFilter }), @@ -130,6 +135,7 @@ export const useInboxSignalsFilterStore = create()( suggestedReviewerFilter: [], }), setViewMode: (viewMode) => set({ viewMode }), + setBoardGroupBy: (boardGroupBy) => set({ boardGroupBy }), }), { name: "inbox-signals-filter-storage", @@ -142,6 +148,7 @@ export const useInboxSignalsFilterStore = create()( hasInitializedSuggestedReviewerFilter: state.hasInitializedSuggestedReviewerFilter, viewMode: state.viewMode, + boardGroupBy: state.boardGroupBy, }), }, ), diff --git a/apps/code/src/renderer/features/inbox/utils/inboxBoardGrouping.ts b/apps/code/src/renderer/features/inbox/utils/inboxBoardGrouping.ts new file mode 100644 index 000000000..6c18ab441 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/utils/inboxBoardGrouping.ts @@ -0,0 +1,129 @@ +import type { InboxBoardGroupBy } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { inboxStatusLabel } from "@features/inbox/utils/inboxSort"; +import type { + SignalReport, + SignalReportActionability, + SignalReportPriority, + SignalReportStatus, +} from "@shared/types"; + +export interface BoardColumnDef { + id: string; + label: string; + accent: string; +} + +const STATUS_COLUMNS: BoardColumnDef[] = ( + [ + "ready", + "pending_input", + "in_progress", + "failed", + "candidate", + "potential", + ] as SignalReportStatus[] +).map((status) => ({ + id: status, + label: inboxStatusLabel(status), + accent: statusAccent(status), +})); + +function statusAccent(status: SignalReportStatus): string { + switch (status) { + case "ready": + return "var(--green-9)"; + case "pending_input": + return "var(--violet-9)"; + case "in_progress": + return "var(--amber-9)"; + case "candidate": + return "var(--cyan-9)"; + case "potential": + return "var(--gray-9)"; + case "failed": + return "var(--red-9)"; + default: + return "var(--gray-8)"; + } +} + +const ACTIONABILITY_COLUMNS: BoardColumnDef[] = [ + { + id: "immediately_actionable", + label: "Actionable", + accent: "var(--green-9)", + }, + { + id: "requires_human_input", + label: "Needs input", + accent: "var(--amber-9)", + }, + { + id: "not_actionable", + label: "Not actionable", + accent: "var(--gray-9)", + }, + { + id: "pending", + label: "In pipeline", + accent: "var(--violet-9)", + }, +]; + +const PRIORITY_COLUMNS: BoardColumnDef[] = [ + { id: "P0", label: "P0", accent: "var(--red-9)" }, + { id: "P1", label: "P1", accent: "var(--orange-9)" }, + { id: "P2", label: "P2", accent: "var(--amber-9)" }, + { id: "P3", label: "P3", accent: "var(--cyan-9)" }, + { id: "P4", label: "P4", accent: "var(--gray-9)" }, + { id: "unprioritized", label: "Unprioritized", accent: "var(--gray-8)" }, +]; + +export function getBoardColumns( + groupBy: InboxBoardGroupBy, + visibleStatuses?: Set, +): BoardColumnDef[] { + if (groupBy === "status") { + return STATUS_COLUMNS.filter( + (c) => + !visibleStatuses || visibleStatuses.has(c.id as SignalReportStatus), + ); + } + if (groupBy === "actionability") return ACTIONABILITY_COLUMNS; + return PRIORITY_COLUMNS; +} + +export function getReportColumnId( + report: SignalReport, + groupBy: InboxBoardGroupBy, +): string { + if (groupBy === "status") { + return report.status; + } + if (groupBy === "actionability") { + if (report.status !== "ready") return "pending"; + const a: SignalReportActionability | null | undefined = + report.actionability; + if (a === "immediately_actionable") return "immediately_actionable"; + if (a === "requires_human_input") return "requires_human_input"; + if (a === "not_actionable") return "not_actionable"; + return "pending"; + } + // priority + const p: SignalReportPriority | null | undefined = report.priority; + if (p === "P0" || p === "P1" || p === "P2" || p === "P3" || p === "P4") { + return p; + } + return "unprioritized"; +} + +export function boardGroupByLabel(groupBy: InboxBoardGroupBy): string { + switch (groupBy) { + case "status": + return "Status"; + case "actionability": + return "Actionability"; + case "priority": + return "Priority"; + } +}