diff --git a/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx b/apps/code/src/renderer/features/inbox/components/InboxEmptyStates.tsx
index 62a61b47a..fc434b8be 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/source-product-icons";
+import { ArrowDownIcon } from "@phosphor-icons/react";
+import { Box, Button, Flex, Text, Tooltip } from "@radix-ui/themes";
+import explorerHog from "@renderer/assets/images/explorer-hog.png";
+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 (
+
+
+
+
+
+ );
+ })}
+
-
+ );
+}
+
+export function SelectReportPane() {
+ return (
+
+
+
+
+ Select a report
+
+
- {isRetrying ? (
-
- ) : (
-
- )}
- Retry
-
+ 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..2b19ede6a 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/source-product-icons";
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 && }
},
];
-function FilterSortMenu({
- sortField,
- sortDirection,
- onSort,
- statusFilter,
- onToggleStatus,
- sourceProductFilter,
- onToggleSourceProduct,
-}: {
- sortField: string;
- sortDirection: string;
- onSort: (
- field: SortOption["field"],
- direction: SortOption["direction"],
- ) => void;
- statusFilter: SignalReportStatus[];
- onToggleStatus: (status: SignalReportStatus) => void;
- sourceProductFilter: SourceProduct[];
- onToggleSourceProduct: (source: SourceProduct) => void;
-}) {
+function FilterSortMenu() {
+ 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 itemClassName =
- "flex w-full items-center justify-between rounded-sm px-1 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3";
+ "flex w-full items-center justify-between rounded-sm px-1 py-1 text-left text-[13px] text-gray-12 transition-colors hover:bg-gray-3 focus-visible:bg-gray-3 focus-visible:outline-none";
+
+ const handleContentKeyDown = (e: KeyboardEvent) => {
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return;
+ e.preventDefault();
+ e.stopPropagation();
+ const container = e.currentTarget;
+ const buttons = Array.from(
+ container.querySelectorAll("button"),
+ );
+ if (buttons.length === 0) return;
+ const currentIndex = buttons.indexOf(
+ document.activeElement as HTMLButtonElement,
+ );
+ const next =
+ e.key === "ArrowDown"
+ ? (currentIndex + 1) % buttons.length
+ : (currentIndex - 1 + buttons.length) % buttons.length;
+ buttons[next].focus();
+ };
return (
-
+