From fbc1d500e951d056952a20c3a98e0bb26e1b3567 Mon Sep 17 00:00:00 2001 From: Oliver Browne Date: Mon, 6 Apr 2026 20:50:26 +0300 Subject: [PATCH] feat: break inbox into some components --- .../inbox/components/InboxEmptyStates.tsx | 258 +++- .../inbox/components/InboxSignalsTab.tsx | 1256 ++--------------- .../inbox/components/InboxSourcesDialog.tsx | 50 + .../inbox/components/InboxWarmingUpState.tsx | 157 --- .../components/detail/ReportDetailPane.tsx | 629 +++++++++ .../{ => detail}/ReportTaskLogs.tsx | 0 .../components/{ => detail}/SignalCard.tsx | 29 +- .../components/{ => list}/ReportCard.tsx | 43 +- .../inbox/components/list/ReportListPane.tsx | 178 +++ .../components/{ => list}/SignalsToolbar.tsx | 87 +- .../components/utils/AnimatedEllipsis.tsx | 15 + .../{ => utils}/SignalReportPriorityBadge.tsx | 0 .../{ => utils}/SignalReportStatusBadge.tsx | 0 .../SignalReportSummaryMarkdown.tsx | 0 .../components/utils/SourceProductIcons.tsx | 50 + .../task-detail/components/TaskDetail.tsx | 32 +- 16 files changed, 1338 insertions(+), 1446 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx delete mode 100644 apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx create mode 100644 apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx rename apps/code/src/renderer/features/inbox/components/{ => detail}/ReportTaskLogs.tsx (100%) rename apps/code/src/renderer/features/inbox/components/{ => detail}/SignalCard.tsx (94%) rename apps/code/src/renderer/features/inbox/components/{ => list}/ReportCard.tsx (83%) create mode 100644 apps/code/src/renderer/features/inbox/components/list/ReportListPane.tsx rename apps/code/src/renderer/features/inbox/components/{ => list}/SignalsToolbar.tsx (89%) create mode 100644 apps/code/src/renderer/features/inbox/components/utils/AnimatedEllipsis.tsx rename apps/code/src/renderer/features/inbox/components/{ => utils}/SignalReportPriorityBadge.tsx (100%) rename apps/code/src/renderer/features/inbox/components/{ => utils}/SignalReportStatusBadge.tsx (100%) rename apps/code/src/renderer/features/inbox/components/{ => utils}/SignalReportSummaryMarkdown.tsx (100%) create mode 100644 apps/code/src/renderer/features/inbox/components/utils/SourceProductIcons.tsx diff --git a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx index 62a61b47a..5a6c8d7c7 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx @@ -1,95 +1,201 @@ -import { - ArrowsClockwiseIcon, - CircleNotchIcon, - WarningIcon, -} from "@phosphor-icons/react"; -import { Box, Button, Flex, Text } from "@radix-ui/themes"; +import { AnimatedEllipsis } from "@features/inbox/components/utils/AnimatedEllipsis"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/SourceProductIcons"; +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"; +import graphsHog from "@renderer/assets/images/graphs-hog.png"; +import mailHog from "@renderer/assets/images/mail-hog.png"; -export function SignalsLoadingState() { +// ── Full-width empty states ───────────────────────────────────────────────── + +export function WelcomePane({ onEnableInbox }: { onEnableInbox: () => void }) { return ( - - - - + + + + + Welcome to your Inbox + + + + + + Background analysis of your data — while you sleep. + +
+ Session recordings watched automatically. Issues, tickets, and evals + analyzed around the clock. +
+ + + + - - - - Loading signals - - -
- - {Array.from({ length: 5 }).map((_, index) => ( - - - - - ))} - + + Ready-to-run fixes for real user problems. + +
+ Each report includes evidence and impact numbers — just execute the + prompt in your agent. +
-
+ + +
); } -export function SignalsErrorState({ - onRetry, - isRetrying, +export function WarmingUpPane({ + onConfigureSources, + enabledProducts, }: { - onRetry: () => void; - isRetrying: boolean; + onConfigureSources: () => void; + enabledProducts: string[]; }) { return ( - - - - - - Could not load signals - - - Check your connection or permissions, then retry. - + + + + + + Inbox is warming up + + + + + Reports will appear here as soon as signals come in. + + + + {enabledProducts.map((sp) => { + const meta = SOURCE_PRODUCT_META[sp]; + if (!meta) return null; + const { Icon } = meta; + return ( + + + + + + ); + })} + - + Pick a report from the list to see details, signals, and evidence. + ); } + +// ── Skeleton rows for backdrop behind empty states ────────────────────────── + +export function SkeletonBackdrop() { + return ( + + {Array.from({ length: 8 }).map((_, index) => ( + + + + + ))} + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index cf46e65c3..c2bbab2e7 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -1,464 +1,35 @@ -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; -import { InboxLiveRail } from "@features/inbox/components/InboxLiveRail"; import { - useInboxReportArtefacts, - useInboxReportSignals, - useInboxReportsInfinite, -} from "@features/inbox/hooks/useInboxReports"; + SelectReportPane, + SkeletonBackdrop, + WarmingUpPane, + WelcomePane, +} from "@features/inbox/components/InboxEmptyStates"; +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 { useInboxCloudTaskStore } from "@features/inbox/stores/inboxCloudTaskStore"; import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignalsSidebarStore"; import { useInboxSourcesDialogStore } from "@features/inbox/stores/inboxSourcesDialogStore"; -import { buildSignalTaskPrompt } from "@features/inbox/utils/buildSignalTaskPrompt"; import { buildSignalReportListOrdering, buildStatusFilterParam, filterReportsBySearch, } from "@features/inbox/utils/filterReports"; import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; -import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { SignalSourcesSettings } from "@features/settings/components/sections/SignalSourcesSettings"; -import { useCreateTask } from "@features/tasks/hooks/useTasks"; -import { useFeatureFlag } from "@hooks/useFeatureFlag"; -import { useRepositoryIntegration } from "@hooks/useIntegrations"; -import { - ArrowDownIcon, - ArrowSquareOutIcon, - ArrowsClockwiseIcon, - BrainIcon, - BugIcon, - CaretRightIcon, - CheckIcon, - CircleNotchIcon, - ClockIcon, - Cloud as CloudIcon, - CommandIcon, - GithubLogoIcon, - KanbanIcon, - KeyReturnIcon, - TicketIcon, - VideoIcon, - WarningIcon, - XIcon, -} from "@phosphor-icons/react"; -import { - AlertDialog, - Badge, - Box, - Button, - Dialog, - Flex, - ScrollArea, - Select, - Text, - Tooltip, -} from "@radix-ui/themes"; -import explorerHog from "@renderer/assets/images/explorer-hog.png"; -import graphsHog from "@renderer/assets/images/graphs-hog.png"; -import mailHog from "@renderer/assets/images/mail-hog.png"; -import { getCloudUrlFromRegion } from "@shared/constants/oauth"; -import type { - SignalReportArtefact, - SignalReportsQueryParams, - SuggestedReviewersArtefact, -} from "@shared/types"; +import { Box, Flex, ScrollArea } from "@radix-ui/themes"; +import type { SignalReportsQueryParams } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { toast } from "sonner"; -import { ReportCard } from "./ReportCard"; -import { ReportTaskLogs } from "./ReportTaskLogs"; -import { SignalCard } from "./SignalCard"; -import { SignalReportPriorityBadge } from "./SignalReportPriorityBadge"; -import { SignalReportStatusBadge } from "./SignalReportStatusBadge"; -import { SignalReportSummaryMarkdown } from "./SignalReportSummaryMarkdown"; -import { SignalsToolbar } from "./SignalsToolbar"; - -function JudgmentBadges({ - safetyContent, - actionabilityContent, -}: { - safetyContent: Record | null; - actionabilityContent: Record | null; -}) { - const [expanded, setExpanded] = useState(false); - - const isSafe = - safetyContent?.safe === true || safetyContent?.judgment === "safe"; - const actionabilityJudgment = - (actionabilityContent?.judgment as string) ?? ""; - - const actionabilityLabel = - actionabilityJudgment === "immediately_actionable" - ? "Immediately actionable" - : actionabilityJudgment === "requires_human_input" - ? "Requires human input" - : "Not actionable"; - - const actionabilityColor = - actionabilityJudgment === "immediately_actionable" - ? "green" - : actionabilityJudgment === "requires_human_input" - ? "amber" - : "gray"; - - return ( - - - {expanded && ( - - {safetyContent?.explanation ? ( - - - Safety - - - {String(safetyContent.explanation)} - - - ) : null} - {actionabilityContent?.explanation ? ( - - - Actionability - - - {String(actionabilityContent.explanation)} - - - ) : null} - - )} - - ); -} - -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} - - ); -} - -// ── Animated ellipsis for warming-up inline text ───────────────────────────── - -function AnimatedEllipsis() { - return ( - - - . - . - . - - - ); -} - -// ── Right pane empty states ───────────────────────────────────────────────── - -function WelcomePane({ onEnableInbox }: { onEnableInbox: () => void }) { - return ( - - - - - - Welcome to your Inbox - - - - - - Background analysis of your data — while you sleep. - -
- Session recordings watched automatically. Issues, tickets, and evals - analyzed around the clock. -
- - - - - - Ready-to-run fixes for real user problems. - -
- Each report includes evidence and impact numbers — just execute the - prompt in your agent. -
-
- - -
-
- ); -} - -const SOURCE_ICON_MAP: Record< - string, - { icon: React.ReactNode; color: string; label: string } -> = { - session_replay: { - icon: , - color: "var(--amber-9)", - label: "Session replay", - }, - error_tracking: { - icon: , - color: "var(--red-9)", - label: "Error tracking", - }, - llm_analytics: { - icon: , - color: "var(--purple-9)", - label: "LLM analytics", - }, - github: { - icon: , - color: "var(--gray-11)", - label: "GitHub", - }, - linear: { - icon: , - color: "var(--blue-9)", - label: "Linear", - }, - zendesk: { - icon: , - color: "var(--green-9)", - label: "Zendesk", - }, -}; - -function WarmingUpPane({ - onConfigureSources, - enabledProducts, -}: { - onConfigureSources: () => void; - enabledProducts: string[]; -}) { - return ( - - - - - - Inbox is warming up - - - - - Reports will appear here as soon as signals come in. - - - - {enabledProducts.map((sp) => { - const info = SOURCE_ICON_MAP[sp]; - return info ? ( - - {info.icon} - - ) : null; - })} - - - - - ); -} - -function SelectReportPane() { - return ( - - - - - Select a report - - - Pick a report from the list to see details, signals, and evidence. - - - - ); -} +import { ReportDetailPane } from "./detail/ReportDetailPane"; +import { ReportListPane } from "./list/ReportListPane"; +import { SignalsToolbar } from "./list/SignalsToolbar"; // ── Main component ────────────────────────────────────────────────────────── export function InboxSignalsTab() { + // ── Filter / sort store ───────────────────────────────────────────────── const sortField = useInboxSignalsFilterStore((s) => s.sortField); const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); const searchQuery = useInboxSignalsFilterStore((s) => s.searchQuery); @@ -466,6 +37,8 @@ export function InboxSignalsTab() { const sourceProductFilter = useInboxSignalsFilterStore( (s) => s.sourceProductFilter, ); + + // ── Signal source configs ─────────────────────────────────────────────── const { data: signalSourceConfigs } = useSignalSourceConfigs(); const hasSignalSources = signalSourceConfigs?.some((c) => c.enabled) ?? false; const enabledProducts = useMemo(() => { @@ -479,13 +52,17 @@ export function InboxSignalsTab() { ) .map((c) => c.source_product); }, [signalSourceConfigs]); + + // ── Sources dialog ────────────────────────────────────────────────────── const sourcesDialogOpen = useInboxSourcesDialogStore((s) => s.open); const setSourcesDialogOpen = useInboxSourcesDialogStore((s) => s.setOpen); + // ── Polling control ───────────────────────────────────────────────────── const windowFocused = useRendererWindowFocusStore((s) => s.focused); const isInboxView = useNavigationStore((s) => s.view.type === "inbox"); const inboxPollingActive = windowFocused && isInboxView; + // ── Data fetching ─────────────────────────────────────────────────────── const inboxQueryParams = useMemo( (): SignalReportsQueryParams => ({ status: buildStatusFilterParam(statusFilter), @@ -513,6 +90,7 @@ export function InboxSignalsTab() { refetchIntervalInBackground: false, staleTime: inboxPollingActive ? INBOX_REFETCH_INTERVAL_MS : 12_000, }); + const reports = useMemo( () => filterReportsBySearch(allReports, searchQuery), [allReports, searchQuery], @@ -526,17 +104,9 @@ export function InboxSignalsTab() { () => allReports.filter((r) => r.status !== "ready").length, [allReports], ); + + // ── Selection state ───────────────────────────────────────────────────── const [selectedReportId, setSelectedReportId] = useState(null); - const sidebarWidth = useInboxSignalsSidebarStore((state) => state.width); - const sidebarIsResizing = useInboxSignalsSidebarStore( - (state) => state.isResizing, - ); - const setSidebarWidth = useInboxSignalsSidebarStore( - (state) => state.setWidth, - ); - const setSidebarIsResizing = useInboxSignalsSidebarStore( - (state) => state.setIsResizing, - ); useEffect(() => { if (reports.length === 0) { @@ -559,185 +129,17 @@ export function InboxSignalsTab() { [reports, selectedReportId], ); - // ── Arrow-key navigation between reports ────────────────────────────── - const reportsRef = useRef(reports); - reportsRef.current = reports; - const selectedReportIdRef = useRef(selectedReportId); - selectedReportIdRef.current = selectedReportId; - - const leftPaneRef = useRef(null); - - // Auto-focus the list pane when the inbox mounts so arrow keys work immediately - useEffect(() => { - leftPaneRef.current?.focus(); - }, []); - - const navigateReport = useCallback((direction: 1 | -1) => { - const list = reportsRef.current; - if (list.length === 0) return; - const currentId = selectedReportIdRef.current; - const currentIndex = currentId - ? list.findIndex((r) => r.id === currentId) - : -1; - const nextIndex = - currentIndex === -1 - ? 0 - : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); - const nextId = list[nextIndex].id; - setSelectedReportId(nextId); - // Move focus back to the list container so the previously clicked card - // loses its focus outline - leftPaneRef.current?.focus(); - leftPaneRef.current - ?.querySelector(`[data-report-id="${nextId}"]`) - ?.scrollIntoView({ block: "nearest" }); - }, []); - - const handleCreateTaskRef = useRef<() => void>(() => {}); - - const handleListKeyDown = useCallback( - (e: React.KeyboardEvent) => { - // Don't capture arrow keys when focus is inside interactive child UI - // like filter popovers, dropdowns, or search inputs - const target = e.target as HTMLElement; - if ( - target.closest( - "[role='menu'], [role='listbox'], [role='dialog'], [data-radix-popper-content-wrapper], input, select, textarea", - ) - ) - return; - - if (e.key === "ArrowDown") { - e.preventDefault(); - navigateReport(1); - } else if (e.key === "ArrowUp") { - e.preventDefault(); - navigateReport(-1); - } else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { - e.preventDefault(); - handleCreateTaskRef.current(); - } - }, - [navigateReport], + // ── Sidebar resize ───────────────────────────────────────────────────── + const sidebarWidth = useInboxSignalsSidebarStore((state) => state.width); + const sidebarIsResizing = useInboxSignalsSidebarStore( + (state) => state.isResizing, ); - - const artefactsQuery = useInboxReportArtefacts(selectedReport?.id ?? "", { - enabled: !!selectedReport, - }); - const allArtefacts = artefactsQuery.data?.results ?? []; - const videoSegments = allArtefacts.filter( - (a): a is SignalReportArtefact => a.type === "video_segment", + const setSidebarWidth = useInboxSignalsSidebarStore( + (state) => state.setWidth, + ); + const setSidebarIsResizing = useInboxSignalsSidebarStore( + (state) => state.setIsResizing, ); - const suggestedReviewers = useMemo(() => { - const reviewerArtefact = allArtefacts.find( - (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", - ); - return reviewerArtefact?.content ?? []; - }, [allArtefacts]); - const judgments = useMemo(() => { - const safety = allArtefacts.find((a) => a.type === "safety_judgment"); - const actionability = allArtefacts.find( - (a) => a.type === "actionability_judgment", - ); - const safetyContent = - safety && !Array.isArray(safety.content) - ? (safety.content as unknown as Record) - : null; - const actionabilityContent = - actionability && !Array.isArray(actionability.content) - ? (actionability.content as unknown as Record) - : null; - return { safetyContent, actionabilityContent }; - }, [allArtefacts]); - - const signalsQuery = useInboxReportSignals(selectedReport?.id ?? "", { - enabled: !!selectedReport, - }); - const signals = signalsQuery.data?.signals ?? []; - - const canActOnReport = !!selectedReport && selectedReport.status === "ready"; - - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const projectId = useAuthStateValue((state) => state.projectId); - const replayBaseUrl = - cloudRegion && projectId - ? `${getCloudUrlFromRegion(cloudRegion)}/project/${projectId}/replay` - : null; - - const { navigateToTaskInput, navigateToTask } = useNavigationStore(); - const draftActions = useDraftStore((s) => s.actions); - const { invalidateTasks } = useCreateTask(); - const { githubIntegration, repositories } = useRepositoryIntegration(); - const cloudModeEnabled = useFeatureFlag("twig-cloud-mode-toggle"); - - const isRunningCloudTask = useInboxCloudTaskStore((s) => s.isRunning); - const showCloudConfirm = useInboxCloudTaskStore((s) => s.showConfirm); - const selectedRepo = useInboxCloudTaskStore((s) => s.selectedRepo); - const openCloudConfirm = useInboxCloudTaskStore((s) => s.openConfirm); - const closeCloudConfirm = useInboxCloudTaskStore((s) => s.closeConfirm); - const setSelectedRepo = useInboxCloudTaskStore((s) => s.setSelectedRepo); - const runCloudTask = useInboxCloudTaskStore((s) => s.runCloudTask); - - const buildPrompt = useCallback(() => { - if (!selectedReport) return null; - return buildSignalTaskPrompt({ - report: selectedReport, - artefacts: videoSegments, - signals, - replayBaseUrl, - }); - }, [selectedReport, videoSegments, signals, replayBaseUrl]); - - const handleCreateTask = () => { - if (!selectedReport || selectedReport.status !== "ready") { - return; - } - const prompt = buildPrompt(); - if (!prompt) return; - - draftActions.setPendingContent("task-input", { - segments: [{ type: "text", text: prompt }], - }); - navigateToTaskInput(); - }; - handleCreateTaskRef.current = handleCreateTask; - - const handleOpenCloudConfirm = useCallback(() => { - openCloudConfirm(repositories[0] ?? null); - }, [repositories, openCloudConfirm]); - - const selectedReportRef = useRef(selectedReport); - selectedReportRef.current = selectedReport; - - const handleRunCloudTask = useCallback(async () => { - const report = selectedReportRef.current; - if (!report || report.status !== "ready") { - return; - } - const prompt = buildPrompt(); - if (!prompt) return; - - const result = await runCloudTask({ - prompt, - githubIntegrationId: githubIntegration?.id, - reportId: report.id, - }); - - if (result.success && result.task) { - invalidateTasks(result.task); - navigateToTask(result.task); - } else if (!result.success) { - toast.error(result.error ?? "Failed to create cloud task"); - } - }, [ - buildPrompt, - runCloudTask, - invalidateTasks, - navigateToTask, - githubIntegration?.id, - ]); - - // Resize handle for left pane const containerRef = useRef(null); const handleResizeMouseDown = useCallback( @@ -777,379 +179,90 @@ export function InboxSignalsTab() { }; }, [sidebarIsResizing, setSidebarWidth, setSidebarIsResizing]); - // ── Layout mode: full-width empty state vs two-pane ───────────────────── - + // ── Layout mode (computed early — needed by focus effect below) ──────── const hasReports = allReports.length > 0; const hasActiveFilters = sourceProductFilter.length > 0 || statusFilter.length < 5; - const showTwoPaneLayout = + const shouldShowTwoPane = hasReports || !!searchQuery.trim() || hasActiveFilters; - // ── Determine right pane content (only used in two-pane mode) ────────── - - let rightPaneContent: React.ReactNode; - - if (selectedReport) { - rightPaneContent = ( - <> - - - - {selectedReport.title ?? "Untitled signal"} - - - - - - - {cloudModeEnabled && ( - - )} - - {selectedReport && ( - - )} - - - - - {/* ── Description ─────────────────────────────────────── */} - {selectedReport.status !== "ready" ? ( - -
- -
-
- ) : ( - - )} - - - {suggestedReviewers.length > 0 && ( - - - Suggested reviewers - - - {suggestedReviewers.map((reviewer) => ( - - - - {reviewer.user?.first_name ?? - reviewer.github_name ?? - reviewer.github_login} - - - @{reviewer.github_login} - - - {reviewer.relevant_commits.length > 0 && ( - - {reviewer.relevant_commits.map((commit, i) => ( - - {i > 0 && ", "} - - - {commit.sha.slice(0, 7)} - - - - ))} - - )} - - ))} - - - )} - - {/* ── Signals ─────────────────────────────────────────── */} - {signals.length > 0 && ( - - - Signals ({signals.length}) - - - {signals.map((signal) => ( - - ))} - - - )} - {signalsQuery.isLoading && ( - - Loading signals... - - )} - - {/* ── LLM judgments ──────────────────────────────────── */} - {(judgments.safetyContent || judgments.actionabilityContent) && ( - - )} - - {/* ── Session segments (video artefacts) ──────────────── */} - {videoSegments.length > 0 && ( - - - Session segments - - - {videoSegments.map((artefact) => ( - - - {artefact.content.content} - - - - - - {artefact.content.start_time - ? new Date( - artefact.content.start_time, - ).toLocaleString() - : "Unknown time"} - - - {replayBaseUrl && artefact.content.session_id && ( - - View replay - - - )} - - - ))} - - - )} -
-
- {/* ── Research task logs (bottom preview + overlay) ─────── */} - - - ); - } else { - rightPaneContent = ; + // Sticky: once we enter two-pane mode, stay there even if a refetch + // momentarily empties the list (e.g. when sort order changes). + const hasMountedTwoPaneRef = useRef(false); + if (shouldShowTwoPane) { + hasMountedTwoPaneRef.current = true; } + const showTwoPaneLayout = hasMountedTwoPaneRef.current; + + // ── Arrow-key navigation between reports ────────────────────────────── + const reportsRef = useRef(reports); + reportsRef.current = reports; + const selectedReportIdRef = useRef(selectedReportId); + selectedReportIdRef.current = selectedReportId; + const leftPaneRef = useRef(null); - // ── Left pane content ─────────────────────────────────────────────────── + // 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(); + }); + } + }, [showTwoPaneLayout]); - let leftPaneList: React.ReactNode; + const navigateReport = useCallback((direction: 1 | -1) => { + const list = reportsRef.current; + if (list.length === 0) return; + const currentId = selectedReportIdRef.current; + const currentIndex = currentId + ? list.findIndex((r) => r.id === currentId) + : -1; + const nextIndex = + currentIndex === -1 + ? 0 + : Math.max(0, Math.min(list.length - 1, currentIndex + direction)); + const nextId = list[nextIndex].id; + setSelectedReportId(nextId); + leftPaneRef.current + ?.querySelector(`[data-report-id="${nextId}"]`) + ?.scrollIntoView({ block: "nearest" }); + }, []); - if (isLoading && allReports.length === 0 && hasSignalSources) { - leftPaneList = ( - - {Array.from({ length: 5 }).map((_, index) => ( - - - - - ))} - - ); - } else if (error) { - leftPaneList = ( - - - - - Could not load signals - - - - - ); - } else if (reports.length === 0 && searchQuery.trim()) { - leftPaneList = ( - - - No matching reports - - - ); - } else if (reports.length === 0 && hasActiveFilters) { - leftPaneList = ( - - - No reports match current filters - - - ); - } else { - leftPaneList = ( - <> - {reports.map((report, index) => ( - setSelectedReportId(report.id)} - /> - ))} - - - ); - } + // Window-level keyboard handler so arrow keys work regardless of which + // pane has focus — only suppressed inside interactive widgets. + useEffect(() => { + const handler = (e: KeyboardEvent) => { + // Don't capture when any Radix overlay or interactive widget is open + if ( + document.querySelector( + "[data-radix-popper-content-wrapper], [role='dialog'][data-state='open']", + ) + ) + return; - // ── Skeleton rows for backdrop behind empty states ────────────────────── + const target = e.target as HTMLElement; + if (target.closest("input, select, textarea")) return; - const skeletonBackdrop = ( - - {Array.from({ length: 8 }).map((_, index) => ( - - - - - ))} - - ); + if (e.key === "ArrowDown") { + e.preventDefault(); + navigateReport(1); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + navigateReport(-1); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [navigateReport]); const searchDisabledReason = !hasReports && !searchQuery.trim() ? "No reports in the project\u2026 yet" : null; + // ── Render ────────────────────────────────────────────────────────────── + return ( <> {showTwoPaneLayout ? ( @@ -1158,12 +271,12 @@ export function InboxSignalsTab() { - + + + - {leftPaneList}
@@ -1208,7 +344,7 @@ export function InboxSignalsTab() { /> - {/* ── Right pane: detail ───────────────────────────────────── */} + {/* ── Right pane: detail ───────────────────────────────── */} - {rightPaneContent} + {selectedReport ? ( + setSelectedReportId(null)} + /> + ) : ( + + )}
) : ( - /* ── Full-width empty state with skeleton backdrop ──────────── */ + /* ── Full-width empty state with skeleton backdrop ──────── */ - {skeletonBackdrop} + )} - {/* ── Sources config dialog ──────────────────────────────────── */} - - - - - Signal sources - - - - - - - - {hasSignalSources ? ( - - - - ) : ( - - - - )} - - - - - {/* ── Cloud task confirmation dialog ────────────────────────── */} - { - if (!open) closeCloudConfirm(); - }} - > - - - - - Run cloud task - - - - - - This will create and run a cloud task from this signal report. - - {repositories.length > 1 ? ( - - - Target repository - - - - - {repositories.map((repo) => ( - - {repo} - - ))} - - - - ) : selectedRepo ? ( - - - Target repository - - - {selectedRepo} - - - ) : null} - - - - - - - - - - - - + {/* ── Sources config dialog ──────────────────────────────── */} + ); } diff --git a/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx b/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx new file mode 100644 index 000000000..1c58cd1cf --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/InboxSourcesDialog.tsx @@ -0,0 +1,50 @@ +import { SignalSourcesSettings } from "@features/settings/components/sections/SignalSourcesSettings"; +import { XIcon } from "@phosphor-icons/react"; +import { Button, Dialog, Flex, Tooltip } from "@radix-ui/themes"; + +interface InboxSourcesDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + hasSignalSources: boolean; +} + +export function InboxSourcesDialog({ + open, + onOpenChange, + hasSignalSources, +}: InboxSourcesDialogProps) { + return ( + + + + + Signal sources + + + + + + + + {hasSignalSources ? ( + + + + ) : ( + + + + )} + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx b/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx deleted file mode 100644 index c2ef1bdba..000000000 --- a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; -import { - BugIcon, - GithubLogoIcon, - KanbanIcon, - SparkleIcon, - TicketIcon, - VideoIcon, -} from "@phosphor-icons/react"; -import { Button, Flex, Text, Tooltip } from "@radix-ui/themes"; -import type { SignalSourceConfig } from "@renderer/api/posthogClient"; -import explorerHog from "@renderer/assets/images/explorer-hog.png"; -import { type ReactNode, useMemo } from "react"; - -const SOURCE_DISPLAY_ORDER: SignalSourceConfig["source_product"][] = [ - "session_replay", - "error_tracking", - "github", - "linear", - "zendesk", -]; - -function sourceIcon(product: SignalSourceConfig["source_product"]): ReactNode { - const common = { size: 20 as const }; - switch (product) { - case "session_replay": - return ; - case "error_tracking": - return ; - case "github": - return ; - case "linear": - return ; - case "zendesk": - return ; - default: - return ; - } -} - -function sourceProductTooltipLabel( - product: SignalSourceConfig["source_product"], -): string { - switch (product) { - case "session_replay": - return "PostHog Session Replay"; - case "error_tracking": - return "PostHog Error Tracking"; - case "github": - return "GitHub Issues"; - case "linear": - return "Linear"; - case "zendesk": - return "Zendesk"; - default: - return "Signal source"; - } -} - -function AnimatedEllipsis({ className }: { className?: string }) { - return ( - - - . - . - . - - - ); -} - -interface InboxWarmingUpStateProps { - onConfigureSources: () => void; -} - -export function InboxWarmingUpState({ - onConfigureSources, -}: InboxWarmingUpStateProps) { - const { data: configs } = useSignalSourceConfigs(); - - const enabledProducts = useMemo(() => { - const seen = new Set(); - return (configs ?? []) - .filter((c) => c.enabled) - .sort( - (a, b) => - SOURCE_DISPLAY_ORDER.indexOf(a.source_product) - - SOURCE_DISPLAY_ORDER.indexOf(b.source_product), - ) - .filter((c) => { - if (seen.has(c.source_product)) return false; - seen.add(c.source_product); - return true; - }); - }, [configs]); - - return ( - - - - - - Inbox is warming up - - - - - Reports will appear here as soon as signals come in. - - - - {enabledProducts.map((cfg) => ( - - - {sourceIcon(cfg.source_product)} - - - ))} - - - - - ); -} diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx new file mode 100644 index 000000000..ca5cc306b --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -0,0 +1,629 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; +import { + useInboxReportArtefacts, + useInboxReportSignals, +} from "@features/inbox/hooks/useInboxReports"; +import { useInboxCloudTaskStore } from "@features/inbox/stores/inboxCloudTaskStore"; +import { buildSignalTaskPrompt } from "@features/inbox/utils/buildSignalTaskPrompt"; +import { useDraftStore } from "@features/message-editor/stores/draftStore"; +import { useCreateTask } from "@features/tasks/hooks/useTasks"; +import { useFeatureFlag } from "@hooks/useFeatureFlag"; +import { useRepositoryIntegration } from "@hooks/useIntegrations"; +import { + ArrowSquareOutIcon, + CaretRightIcon, + CheckIcon, + ClockIcon, + Cloud as CloudIcon, + CommandIcon, + GithubLogoIcon, + KeyReturnIcon, + WarningIcon, + XIcon, +} from "@phosphor-icons/react"; +import { + AlertDialog, + Badge, + Box, + Button, + Flex, + ScrollArea, + Select, + Text, + Tooltip, +} from "@radix-ui/themes"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; +import type { + SignalReport, + SignalReportArtefact, + SuggestedReviewersArtefact, +} from "@shared/types"; +import { useNavigationStore } from "@stores/navigationStore"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "sonner"; +import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; +import { SignalReportStatusBadge } from "../utils/SignalReportStatusBadge"; +import { SignalReportSummaryMarkdown } from "../utils/SignalReportSummaryMarkdown"; +import { ReportTaskLogs } from "./ReportTaskLogs"; +import { SignalCard } from "./SignalCard"; + +// ── JudgmentBadges (only used in detail pane) ─────────────────────────────── + +function JudgmentBadges({ + safetyContent, + actionabilityContent, +}: { + safetyContent: Record | null; + actionabilityContent: Record | null; +}) { + const [expanded, setExpanded] = useState(false); + + const isSafe = + safetyContent?.safe === true || safetyContent?.judgment === "safe"; + const actionabilityJudgment = + (actionabilityContent?.judgment as string) ?? ""; + + const actionabilityLabel = + actionabilityJudgment === "immediately_actionable" + ? "Immediately actionable" + : actionabilityJudgment === "requires_human_input" + ? "Requires human input" + : "Not actionable"; + + const actionabilityColor = + actionabilityJudgment === "immediately_actionable" + ? "green" + : actionabilityJudgment === "requires_human_input" + ? "amber" + : "gray"; + + return ( + + + {expanded && ( + + {safetyContent?.explanation ? ( + + + Safety + + + {String(safetyContent.explanation)} + + + ) : null} + {actionabilityContent?.explanation ? ( + + + Actionability + + + {String(actionabilityContent.explanation)} + + + ) : null} + + )} + + ); +} + +// ── ReportDetailPane ──────────────────────────────────────────────────────── + +interface ReportDetailPaneProps { + report: SignalReport; + onClose: () => void; +} + +export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { + // ── Auth / URLs ───────────────────────────────────────────────────────── + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + const projectId = useAuthStateValue((state) => state.projectId); + const replayBaseUrl = + cloudRegion && projectId + ? `${getCloudUrlFromRegion(cloudRegion)}/project/${projectId}/replay` + : null; + + // ── Report data ───────────────────────────────────────────────────────── + const artefactsQuery = useInboxReportArtefacts(report.id, { + enabled: true, + }); + const allArtefacts = artefactsQuery.data?.results ?? []; + + const videoSegments = allArtefacts.filter( + (a): a is SignalReportArtefact => a.type === "video_segment", + ); + + const suggestedReviewers = useMemo(() => { + const reviewerArtefact = allArtefacts.find( + (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + ); + return reviewerArtefact?.content ?? []; + }, [allArtefacts]); + + const judgments = useMemo(() => { + const safety = allArtefacts.find((a) => a.type === "safety_judgment"); + const actionability = allArtefacts.find( + (a) => a.type === "actionability_judgment", + ); + const safetyContent = + safety && !Array.isArray(safety.content) + ? (safety.content as unknown as Record) + : null; + const actionabilityContent = + actionability && !Array.isArray(actionability.content) + ? (actionability.content as unknown as Record) + : null; + return { safetyContent, actionabilityContent }; + }, [allArtefacts]); + + const signalsQuery = useInboxReportSignals(report.id, { + enabled: true, + }); + const signals = signalsQuery.data?.signals ?? []; + + // ── Task creation ─────────────────────────────────────────────────────── + const { navigateToTaskInput, navigateToTask } = useNavigationStore(); + const draftActions = useDraftStore((s) => s.actions); + const { invalidateTasks } = useCreateTask(); + const { githubIntegration, repositories } = useRepositoryIntegration(); + const cloudModeEnabled = useFeatureFlag("twig-cloud-mode-toggle"); + + const isRunningCloudTask = useInboxCloudTaskStore((s) => s.isRunning); + const showCloudConfirm = useInboxCloudTaskStore((s) => s.showConfirm); + const selectedRepo = useInboxCloudTaskStore((s) => s.selectedRepo); + const openCloudConfirm = useInboxCloudTaskStore((s) => s.openConfirm); + const closeCloudConfirm = useInboxCloudTaskStore((s) => s.closeConfirm); + const setSelectedRepo = useInboxCloudTaskStore((s) => s.setSelectedRepo); + const runCloudTask = useInboxCloudTaskStore((s) => s.runCloudTask); + + const canActOnReport = report.status === "ready"; + + const buildPrompt = useCallback(() => { + return buildSignalTaskPrompt({ + report, + artefacts: videoSegments, + signals, + replayBaseUrl, + }); + }, [report, videoSegments, signals, replayBaseUrl]); + + const handleCreateTask = useCallback(() => { + if (!canActOnReport) return; + const prompt = buildPrompt(); + if (!prompt) return; + + draftActions.setPendingContent("task-input", { + segments: [{ type: "text", text: prompt }], + }); + navigateToTaskInput(); + }, [canActOnReport, buildPrompt, draftActions, navigateToTaskInput]); + + // Cmd/Ctrl+Enter shortcut to create task + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleCreateTask(); + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [handleCreateTask]); + + const handleOpenCloudConfirm = useCallback(() => { + openCloudConfirm(repositories[0] ?? null); + }, [repositories, openCloudConfirm]); + + const handleRunCloudTask = useCallback(async () => { + if (!canActOnReport) return; + const prompt = buildPrompt(); + if (!prompt) return; + + const result = await runCloudTask({ + prompt, + githubIntegrationId: githubIntegration?.id, + reportId: report.id, + }); + + if (result.success && result.task) { + invalidateTasks(result.task); + navigateToTask(result.task); + } else if (!result.success) { + toast.error(result.error ?? "Failed to create cloud task"); + } + }, [ + canActOnReport, + buildPrompt, + runCloudTask, + invalidateTasks, + navigateToTask, + githubIntegration?.id, + report.id, + ]); + + return ( + <> + {/* ── Header bar ──────────────────────────────────────────── */} + + + + {report.title ?? "Untitled signal"} + + + + + + + {cloudModeEnabled && ( + + )} + + + + + + {/* ── Scrollable detail area ──────────────────────────────── */} + + + {/* ── Description ─────────────────────────────────────── */} + {report.status !== "ready" ? ( + +
+ +
+
+ ) : ( + + )} + + + {/* ── Suggested reviewers ─────────────────────────────── */} + {suggestedReviewers.length > 0 && ( + + + Suggested reviewers + + + {suggestedReviewers.map((reviewer) => ( + + + + {reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login} + + + @{reviewer.github_login} + + + {reviewer.relevant_commits.length > 0 && ( + + {reviewer.relevant_commits.map((commit, i) => ( + + {i > 0 && ", "} + + + {commit.sha.slice(0, 7)} + + + + ))} + + )} + + ))} + + + )} + + {/* ── Signals ─────────────────────────────────────────── */} + {signals.length > 0 && ( + + + Signals ({signals.length}) + + + {signals.map((signal) => ( + + ))} + + + )} + {signalsQuery.isLoading && ( + + Loading signals... + + )} + + {/* ── LLM judgments ──────────────────────────────────── */} + {(judgments.safetyContent || judgments.actionabilityContent) && ( + + )} + + {/* ── Session segments (video artefacts) ──────────────── */} + {videoSegments.length > 0 && ( + + + Session segments + + + {videoSegments.map((artefact) => ( + + + {artefact.content.content} + + + + + + {artefact.content.start_time + ? new Date( + artefact.content.start_time, + ).toLocaleString() + : "Unknown time"} + + + {replayBaseUrl && artefact.content.session_id && ( + + View replay + + + )} + + + ))} + + + )} +
+
+ + {/* ── Research task logs (bottom preview + overlay) ─────── */} + + + {/* ── Cloud task confirmation dialog ────────────────────── */} + { + if (!open) closeCloudConfirm(); + }} + > + + + + + Run cloud task + + + + + + This will create and run a cloud task from this signal report. + + {repositories.length > 1 ? ( + + + Target repository + + + + + {repositories.map((repo) => ( + + {repo} + + ))} + + + + ) : selectedRepo ? ( + + + Target repository + + + {selectedRepo} + + + ) : null} + + + + + + + + + + + + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/ReportTaskLogs.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx similarity index 100% rename from apps/code/src/renderer/features/inbox/components/ReportTaskLogs.tsx rename to apps/code/src/renderer/features/inbox/components/detail/ReportTaskLogs.tsx diff --git a/apps/code/src/renderer/features/inbox/components/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx similarity index 94% rename from apps/code/src/renderer/features/inbox/components/SignalCard.tsx rename to apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 728152403..74551e473 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -1,15 +1,10 @@ import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; +import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/SourceProductIcons"; import { ArrowSquareOutIcon, - BrainIcon, - BugIcon, CaretDownIcon, CaretRightIcon, - GithubLogoIcon, - KanbanIcon, TagIcon, - TicketIcon, - VideoIcon, WarningIcon, } from "@phosphor-icons/react"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; @@ -67,20 +62,6 @@ function signalCardSourceLine(signal: { return `${productLabel} · ${typeLabel}`; } -// ── Source product color (matching Cloud's known product colors) ────────────── - -const SOURCE_PRODUCT_ICONS: Record< - string, - { icon: React.ReactNode; color: string } -> = { - session_replay: { icon: , color: "var(--amber-9)" }, - error_tracking: { icon: , color: "var(--red-9)" }, - llm_analytics: { icon: , color: "var(--purple-9)" }, - github: { icon: , color: "var(--gray-11)" }, - linear: { icon: , color: "var(--blue-9)" }, - zendesk: { icon: , color: "var(--green-9)" }, -}; - // ── Shared utilities ───────────────────────────────────────────────────────── interface GitHubLabelObject { @@ -200,15 +181,17 @@ function isErrorTrackingExtra( // ── Shared components ──────────────────────────────────────────────────────── function SignalCardHeader({ signal }: { signal: Signal }) { - const productInfo = SOURCE_PRODUCT_ICONS[signal.source_product]; + const meta = SOURCE_PRODUCT_META[signal.source_product]; return ( - {productInfo?.icon ?? ( + {meta ? ( + + ) : ( = { - session_replay: { icon: , color: "var(--amber-9)" }, - error_tracking: { icon: , color: "var(--red-9)" }, - llm_analytics: { icon: , color: "var(--purple-9)" }, - github: { icon: , color: "var(--gray-11)" }, - linear: { icon: , color: "var(--blue-9)" }, - zendesk: { icon: , color: "var(--green-9)" }, -}; - interface ReportCardProps { report: SignalReport; isSelected: boolean; @@ -85,7 +66,7 @@ export function ReportCard({ ease: [0.22, 1, 0.36, 1], }} onClick={handleActivate} - onKeyDown={(e) => { + onKeyDown={(e: KeyboardEvent) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); handleActivate(e); @@ -114,12 +95,14 @@ export function ReportCard({ > {(report.source_products ?? []).length > 0 ? ( (report.source_products ?? []).map((sp) => { - const info = SOURCE_PRODUCT_ICONS[sp]; - return info ? ( - - {info.icon} + const meta = SOURCE_PRODUCT_META[sp]; + if (!meta) return null; + const { Icon } = meta; + return ( + + - ) : null; + ); }) ) : ( 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} + + ); +} + +// ── ReportListPane ────────────────────────────────────────────────────────── + +interface ReportListPaneProps { + 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; + selectedReportId: string | null; + onSelectReport: (id: string) => void; +} + +export function ReportListPane({ + reports, + allReports, + isLoading, + isFetching, + error, + refetch, + hasNextPage, + isFetchingNextPage, + fetchNextPage, + hasSignalSources, + searchQuery, + hasActiveFilters, + selectedReportId, + onSelectReport, +}: ReportListPaneProps) { + // ── Loading skeleton ──────────────────────────────────────────────────── + if (isLoading && allReports.length === 0 && hasSignalSources) { + return ( + + {Array.from({ length: 5 }).map((_, index) => ( + + + + + ))} + + ); + } + + // ── 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 + + + ); + } + + // ── Report list ───────────────────────────────────────────────────────── + return ( + <> + {reports.map((report, index) => ( + onSelectReport(report.id)} + /> + ))} + + + ); +} diff --git a/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx similarity index 89% rename from apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx rename to apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx index 9dc50d73f..4601e8e18 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/SignalsToolbar.tsx @@ -26,6 +26,7 @@ import type { SignalReportOrderingField, SignalReportStatus, } from "@shared/types"; +import type { KeyboardEvent } from "react"; interface SignalsToolbarProps { totalCount: number; @@ -95,17 +96,6 @@ export function SignalsToolbar({ }: SignalsToolbarProps) { const searchQuery = useInboxSignalsFilterStore((s) => s.searchQuery); const setSearchQuery = useInboxSignalsFilterStore((s) => s.setSearchQuery); - const sortField = useInboxSignalsFilterStore((s) => s.sortField); - const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); - const setSort = useInboxSignalsFilterStore((s) => s.setSort); - const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); - const toggleStatus = useInboxSignalsFilterStore((s) => s.toggleStatus); - const sourceProductFilter = useInboxSignalsFilterStore( - (s) => s.sourceProductFilter, - ); - const toggleSourceProduct = useInboxSignalsFilterStore( - (s) => s.toggleSourceProduct, - ); const countLabel = isSearchActive ? `${filteredCount} of ${totalCount}` @@ -149,17 +139,7 @@ export function SignalsToolbar({ ) : null} - {!hideFilters && ( - - )} + {!hideFilters && }
+ {openTargetPath && } - {openTargetPath && } ), - [task.title, openTargetPath], + [task.title, taskId, openTargetPath, copyTaskId], ); useSetHeaderContent(headerContent);