diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index c870611b6..c105ab7ef 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -20,6 +20,8 @@ export type McpRecommendedServer = Schemas.RecommendedServer; export type McpServerInstallation = Schemas.MCPServerInstallation; +export type Evaluation = Schemas.Evaluation; + export interface SignalSourceConfig { id: string; source_product: @@ -27,8 +29,16 @@ export interface SignalSourceConfig { | "llm_analytics" | "github" | "linear" - | "zendesk"; - source_type: "session_analysis_cluster" | "evaluation" | "issue" | "ticket"; + | "zendesk" + | "error_tracking"; + source_type: + | "session_analysis_cluster" + | "evaluation" + | "issue" + | "ticket" + | "issue_created" + | "issue_reopened" + | "issue_spiking"; enabled: boolean; config: Record; created_at: string; @@ -223,17 +233,8 @@ export class PostHogAPIClient { async createSignalSourceConfig( projectId: number, options: { - source_product: - | "session_replay" - | "llm_analytics" - | "github" - | "linear" - | "zendesk"; - source_type: - | "session_analysis_cluster" - | "evaluation" - | "issue" - | "ticket"; + source_product: SignalSourceConfig["source_product"]; + source_type: SignalSourceConfig["source_type"]; enabled: boolean; config?: Record; }, @@ -287,6 +288,34 @@ export class PostHogAPIClient { return (await response.json()) as SignalSourceConfig; } + async listEvaluations(projectId: number): Promise { + const data = await this.api.get( + "/api/environments/{project_id}/evaluations/", + { + path: { project_id: projectId.toString() }, + query: { limit: 200 }, + }, + ); + return data.results ?? []; + } + + async updateEvaluation( + projectId: number, + evaluationId: string, + updates: { enabled: boolean }, + ): Promise { + return await this.api.patch( + "/api/environments/{project_id}/evaluations/{id}/", + { + path: { + project_id: projectId.toString(), + id: evaluationId, + }, + body: updates, + }, + ); + } + async listExternalDataSources( projectId: number, ): Promise { diff --git a/apps/code/src/renderer/assets/images/explorer-hog.png b/apps/code/src/renderer/assets/images/explorer-hog.png new file mode 100644 index 000000000..95df75d4c Binary files /dev/null and b/apps/code/src/renderer/assets/images/explorer-hog.png differ diff --git a/apps/code/src/renderer/assets/images/graphs-hog.png b/apps/code/src/renderer/assets/images/graphs-hog.png new file mode 100644 index 000000000..fa0b1e9c7 Binary files /dev/null and b/apps/code/src/renderer/assets/images/graphs-hog.png differ diff --git a/apps/code/src/renderer/assets/images/mail-hog.png b/apps/code/src/renderer/assets/images/mail-hog.png new file mode 100644 index 000000000..9f95210b0 Binary files /dev/null and b/apps/code/src/renderer/assets/images/mail-hog.png differ diff --git a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index 381dbb28c..c74618fce 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -51,13 +51,41 @@ interface SetupFormProps { onCancel: () => void; } +const POLL_INTERVAL_GITHUB_MS = 3_000; +const POLL_TIMEOUT_GITHUB_MS = 300_000; + function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { const projectId = useAuthStore((s) => s.projectId); + const cloudRegion = useAuthStore((s) => s.cloudRegion); const client = useAuthStore((s) => s.client); const { githubIntegration, repositories, isLoadingRepos } = useRepositoryIntegration(); const [repo, setRepo] = useState(null); const [loading, setLoading] = useState(false); + const [connecting, setConnecting] = useState(false); + const pollTimerRef = useRef | null>(null); + const pollTimeoutRef = useRef | null>(null); + + const stopPolling = useCallback(() => { + if (pollTimerRef.current) { + clearInterval(pollTimerRef.current); + pollTimerRef.current = null; + } + if (pollTimeoutRef.current) { + clearTimeout(pollTimeoutRef.current); + pollTimeoutRef.current = null; + } + }, []); + + useEffect(() => stopPolling, [stopPolling]); + + // Stop polling once integration appears + useEffect(() => { + if (githubIntegration && connecting) { + stopPolling(); + setConnecting(false); + } + }, [githubIntegration, connecting, stopPolling]); // Auto-select the first repo once loaded useEffect(() => { @@ -66,6 +94,47 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { } }, [repo, repositories]); + const handleConnectGitHub = useCallback(async () => { + if (!cloudRegion || !projectId) return; + setConnecting(true); + try { + await trpcClient.githubIntegration.startFlow.mutate({ + region: cloudRegion, + projectId, + }); + + pollTimerRef.current = setInterval(async () => { + try { + if (!client) return; + // Trigger a refetch of integrations + const integrations = + await client.getIntegrationsForProject(projectId); + const hasGithub = integrations.some( + (i: { kind: string }) => i.kind === "github", + ); + if (hasGithub) { + stopPolling(); + setConnecting(false); + toast.success("GitHub connected"); + } + } catch { + // Ignore individual poll failures + } + }, POLL_INTERVAL_GITHUB_MS); + + pollTimeoutRef.current = setTimeout(() => { + stopPolling(); + setConnecting(false); + toast.error("Connection timed out. Please try again."); + }, POLL_TIMEOUT_GITHUB_MS); + } catch (error) { + setConnecting(false); + toast.error( + error instanceof Error ? error.message : "Failed to start GitHub flow", + ); + } + }, [cloudRegion, projectId, client, stopPolling]); + const handleSubmit = useCallback(async () => { if (!projectId || !client || !repo || !githubIntegration) return; @@ -96,10 +165,28 @@ function GitHubSetup({ onComplete, onCancel }: SetupFormProps) { if (!githubIntegration) { return ( - - No GitHub integration found. Please connect GitHub during onboarding - first. - + + + Connect your GitHub account to import issues as signals. + + + + + + ); } diff --git a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx index e759cef77..3b60e1f0c 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -1,4 +1,3 @@ -import { ResizableSidebar } from "@components/ResizableSidebar"; import { useAuthStore } from "@features/auth/stores/authStore"; import { InboxLiveRail } from "@features/inbox/components/InboxLiveRail"; import { @@ -13,21 +12,24 @@ import { useInboxSignalsSidebarStore } from "@features/inbox/stores/inboxSignals import { buildSignalTaskPrompt } from "@features/inbox/utils/buildSignalTaskPrompt"; import { buildSignalReportListOrdering, + buildStatusFilterParam, filterReportsBySearch, } from "@features/inbox/utils/filterReports"; -import { - INBOX_PIPELINE_STATUS_FILTER, - INBOX_REFETCH_INTERVAL_MS, -} from "@features/inbox/utils/inboxConstants"; +import { INBOX_REFETCH_INTERVAL_MS } from "@features/inbox/utils/inboxConstants"; import { useDraftStore } from "@features/message-editor/stores/draftStore"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; +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, + CircleNotchIcon, ClockIcon, Cloud as CloudIcon, + GithubLogoIcon, + WarningIcon, XIcon, } from "@phosphor-icons/react"; import { @@ -35,22 +37,27 @@ import { 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, SignalReportArtefactsResponse, SignalReportsQueryParams, + SuggestedReviewersArtefact, } 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 { SignalsErrorState, SignalsLoadingState } from "./InboxEmptyStates"; -import { InboxWarmingUpState } from "./InboxWarmingUpState"; import { ReportCard } from "./ReportCard"; import { SignalCard } from "./SignalCard"; import { SignalReportPriorityBadge } from "./SignalReportPriorityBadge"; @@ -114,13 +121,187 @@ function LoadMoreTrigger({ ); } +// ── 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. +
+
+ + +
+
+ ); +} + +function WarmingUpPane({ + onConfigureSources, +}: { + onConfigureSources: () => void; +}) { + return ( + + + + + + Inbox is warming up + + + + + Reports will appear here as soon as signals come in. + + + + + + ); +} + +function SelectReportPane() { + return ( + + + + + Select a report + + + Pick a report from the list to see details, signals, and evidence. + + + + ); +} + +// ── Main component ────────────────────────────────────────────────────────── + export function InboxSignalsTab() { const sortField = useInboxSignalsFilterStore((s) => s.sortField); const sortDirection = useInboxSignalsFilterStore((s) => s.sortDirection); const searchQuery = useInboxSignalsFilterStore((s) => s.searchQuery); + const statusFilter = useInboxSignalsFilterStore((s) => s.statusFilter); const { data: signalSourceConfigs } = useSignalSourceConfigs(); const hasSignalSources = signalSourceConfigs?.some((c) => c.enabled) ?? false; - const openSettings = useSettingsDialogStore((s) => s.open); + const [sourcesDialogOpen, setSourcesDialogOpen] = useState(false); const windowFocused = useRendererWindowFocusStore((s) => s.focused); const isInboxView = useNavigationStore((s) => s.view.type === "inbox"); @@ -128,10 +309,10 @@ export function InboxSignalsTab() { const inboxQueryParams = useMemo( (): SignalReportsQueryParams => ({ - status: INBOX_PIPELINE_STATUS_FILTER, + status: buildStatusFilterParam(statusFilter), ordering: buildSignalReportListOrdering(sortField, sortDirection), }), - [sortField, sortDirection], + [statusFilter, sortField, sortDirection], ); const { @@ -163,12 +344,10 @@ export function InboxSignalsTab() { [allReports], ); const [selectedReportId, setSelectedReportId] = useState(null); - const sidebarOpen = useInboxSignalsSidebarStore((state) => state.open); const sidebarWidth = useInboxSignalsSidebarStore((state) => state.width); const sidebarIsResizing = useInboxSignalsSidebarStore( (state) => state.isResizing, ); - const setSidebarOpen = useInboxSignalsSidebarStore((state) => state.setOpen); const setSidebarWidth = useInboxSignalsSidebarStore( (state) => state.setWidth, ); @@ -189,9 +368,8 @@ export function InboxSignalsTab() { ); if (!selectedExists) { setSelectedReportId(null); - setSidebarOpen(false); } - }, [reports, selectedReportId, setSidebarOpen]); + }, [reports, selectedReportId]); const selectedReport = useMemo( () => reports.find((report) => report.id === selectedReportId) ?? null, @@ -201,7 +379,16 @@ export function InboxSignalsTab() { const artefactsQuery = useInboxReportArtefacts(selectedReport?.id ?? "", { enabled: !!selectedReport, }); - const visibleArtefacts = artefactsQuery.data?.results ?? []; + const allArtefacts = artefactsQuery.data?.results ?? []; + const visibleArtefacts = allArtefacts.filter( + (a): a is SignalReportArtefact => a.type !== "suggested_reviewers", + ); + const suggestedReviewers = useMemo(() => { + const reviewerArtefact = allArtefacts.find( + (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", + ); + return reviewerArtefact?.content ?? []; + }, [allArtefacts]); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; const showArtefactsUnavailable = !artefactsQuery.isLoading && @@ -296,302 +483,507 @@ export function InboxSignalsTab() { githubIntegration?.id, ]); - if (isLoading) { - return ; - } + // Resize handle for left pane + const containerRef = useRef(null); - if (error) { - return ( - { - void refetch(); - }} - isRetrying={isFetching} - /> - ); - } + const handleResizeMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setSidebarIsResizing(true); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }, + [setSidebarIsResizing], + ); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!sidebarIsResizing || !containerRef.current) return; + const containerLeft = containerRef.current.getBoundingClientRect().left; + const containerWidth = containerRef.current.offsetWidth; + const maxWidth = containerWidth * 0.6; + const newWidth = Math.max( + 220, + Math.min(maxWidth, e.clientX - containerLeft), + ); + setSidebarWidth(newWidth); + }; + const handleMouseUp = () => { + if (sidebarIsResizing) { + setSidebarIsResizing(false); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + } + }; + document.addEventListener("mousemove", handleMouseMove); + document.addEventListener("mouseup", handleMouseUp); + return () => { + document.removeEventListener("mousemove", handleMouseMove); + document.removeEventListener("mouseup", handleMouseUp); + }; + }, [sidebarIsResizing, setSidebarWidth, setSidebarIsResizing]); + + // ── Layout mode: full-width empty state vs two-pane ───────────────────── - if (allReports.length === 0) { - if (!hasSignalSources) { - return ( + const hasReports = allReports.length > 0; + const showTwoPaneLayout = hasReports || !!searchQuery.trim(); + + // ── Determine right pane content (only used in two-pane mode) ────────── + + let rightPaneContent: React.ReactNode; + + if (selectedReport) { + rightPaneContent = ( + <> - + - Enable Inbox - - - Inbox automatically analyzes your product data and prioritizes - actionable tasks. Choose which sources to enable for this project. + {selectedReport.title ?? "Untitled signal"} + - + + + {cloudModeEnabled && ( + + )} + + {!canActOnReport && selectedReport ? ( + + {selectedReport.status === "pending_input" + ? "This report needs input in PostHog before an agent can act on it." + : "Research is still running — you can read context below, then create a task when status is Ready."} + + ) : null} - ); - } - return ; - } - - return ( - - - - - - {reports.length === 0 && searchQuery.trim() ? ( - - - No matching signals - - - ) : null} - {reports.map((report, index) => ( - { - setSelectedReportId(report.id); - setSidebarOpen(true); - }} - /> - ))} - + - - - - - - {selectedReport ? ( - <> - - + + + + {selectedReport.signal_count} occurrences + + + {selectedReport.relevant_user_count ?? 0} affected users + + + + {suggestedReviewers.length > 0 && ( + - {selectedReport.title ?? "Untitled signal"} + Suggested reviewers - - - - - {cloudModeEnabled && ( - - )} - - {!canActOnReport && selectedReport ? ( + + {suggestedReviewers.map((reviewer) => ( + + + + {reviewer.user?.first_name ?? + reviewer.github_name ?? + reviewer.github_login} + + + @{reviewer.github_login} + + + ))} + + + )} + + {signals.length > 0 && ( + - {selectedReport.status === "pending_input" - ? "This report needs input in PostHog before an agent can act on it." - : "Research is still running — you can read context below, then create a task when status is Ready."} + Signals ({signals.length}) - ) : null} - - - - - - - - {selectedReport.signal_count} occurrences - - - {selectedReport.relevant_user_count ?? 0} affected users - + + {signals.map((signal) => ( + + ))} + + )} + {signalsQuery.isLoading && ( + + Loading signals... + + )} + + + + Evidence + + {artefactsQuery.isLoading && ( + + Loading evidence... + + )} + {showArtefactsUnavailable && ( + + {artefactsUnavailableMessage} + + )} + {!artefactsQuery.isLoading && + !showArtefactsUnavailable && + visibleArtefacts.length === 0 && ( + + No artefacts were returned for this signal. + + )} - {signals.length > 0 && ( - + + {visibleArtefacts.map((artefact) => ( + - Signals ({signals.length}) + {artefact.content.content} - - {signals.map((signal) => ( - - ))} + + + + + {artefact.content.start_time + ? new Date( + artefact.content.start_time, + ).toLocaleString() + : "Unknown time"} + + + {replayBaseUrl && artefact.content.session_id && ( + + View replay + + + )} - )} - {signalsQuery.isLoading && ( - - Loading signals... - - )} + ))} + + + + + + ); + } else { + rightPaneContent = ; + } - - - Evidence - - {artefactsQuery.isLoading && ( - - Loading evidence... - - )} - {showArtefactsUnavailable && ( - - {artefactsUnavailableMessage} - - )} - {!artefactsQuery.isLoading && - !showArtefactsUnavailable && - visibleArtefacts.length === 0 && ( - - No artefacts were returned for this signal. - - )} + // ── Left pane content ─────────────────────────────────────────────────── - - {visibleArtefacts.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 - - - )} - - - ))} - - + let leftPaneList: React.ReactNode; + + 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 signals + + + ); + } else { + leftPaneList = ( + <> + {reports.map((report, index) => ( + setSelectedReportId(report.id)} + /> + ))} + + + ); + } + + // ── Skeleton rows for backdrop behind empty states ────────────────────── + + const skeletonBackdrop = ( + + {Array.from({ length: 8 }).map((_, index) => ( + + + + + ))} + + ); + + const searchDisabledReason = + !hasReports && !searchQuery.trim() + ? "No reports in the project\u2026 yet" + : null; + + return ( + <> + {showTwoPaneLayout ? ( + + {/* ── Left pane: report list ───────────────────────────────── */} + + + + + + {leftPaneList} - - ) : null} - + {/* Resize handle */} + + + + {/* ── Right pane: detail ───────────────────────────────────── */} + + {rightPaneContent} + + + ) : ( + /* ── Full-width empty state with skeleton backdrop ──────────── */ + + + + {skeletonBackdrop} + + + {!hasSignalSources ? ( + setSourcesDialogOpen(true)} /> + ) : ( + setSourcesDialogOpen(true)} + /> + )} + + + )} + + {/* ── Sources config dialog ──────────────────────────────────── */} + + + + + Signal sources + + + + + + + + {hasSignalSources ? ( + + + + ) : ( + + + + )} + + + + + {/* ── Cloud task confirmation dialog ────────────────────────── */} { @@ -660,6 +1052,6 @@ export function InboxSignalsTab() { - + ); } diff --git a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx b/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx index 427ad723d..c2ef1bdba 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx @@ -1,33 +1,32 @@ import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { - BrainIcon, + BugIcon, GithubLogoIcon, KanbanIcon, SparkleIcon, TicketIcon, VideoIcon, } from "@phosphor-icons/react"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Button, Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalSourceConfig } from "@renderer/api/posthogClient"; -import { motion } from "framer-motion"; +import explorerHog from "@renderer/assets/images/explorer-hog.png"; import { type ReactNode, useMemo } from "react"; const SOURCE_DISPLAY_ORDER: SignalSourceConfig["source_product"][] = [ "session_replay", - "llm_analytics", + "error_tracking", "github", "linear", "zendesk", ]; function sourceIcon(product: SignalSourceConfig["source_product"]): ReactNode { - const common = { size: 22 as const }; + const common = { size: 20 as const }; switch (product) { case "session_replay": return ; - case "llm_analytics": - return ; + case "error_tracking": + return ; case "github": return ; case "linear": @@ -39,6 +38,25 @@ function sourceIcon(product: SignalSourceConfig["source_product"]): ReactNode { } } +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 ( @@ -51,17 +69,29 @@ function AnimatedEllipsis({ className }: { className?: string }) { ); } -export function InboxWarmingUpState() { +interface InboxWarmingUpStateProps { + onConfigureSources: () => void; +} + +export function InboxWarmingUpState({ + onConfigureSources, +}: InboxWarmingUpStateProps) { const { data: configs } = useSignalSourceConfigs(); - const openSignalSettings = useSettingsDialogStore((s) => s.open); - const enabledSources = useMemo(() => { - const enabled = (configs ?? []).filter((c) => c.enabled); - return [...enabled].sort( - (a, b) => - SOURCE_DISPLAY_ORDER.indexOf(a.source_product) - - SOURCE_DISPLAY_ORDER.indexOf(b.source_product), - ); + 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 ( @@ -69,88 +99,59 @@ export function InboxWarmingUpState() { direction="column" align="center" justify="center" - gap="4" height="100%" - px="4" - className="text-center" + px="5" + style={{ margin: "0 auto" }} > - - - + + - - + Inbox is warming up + - - Reports appear here as soon as signals are grouped. Research usually - finishes within a minute while we watch your connected sources. - - - Processing signals - - - - {enabledSources.length > 0 ? ( - - + + + {enabledProducts.map((cfg) => ( + + + {sourceIcon(cfg.source_product)} + + + ))} + - - - ) : null} + Configure sources + + + ); } diff --git a/apps/code/src/renderer/features/inbox/components/ReportCard.tsx b/apps/code/src/renderer/features/inbox/components/ReportCard.tsx index bf018b8f3..9c4b70c36 100644 --- a/apps/code/src/renderer/features/inbox/components/ReportCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/ReportCard.tsx @@ -4,7 +4,8 @@ import { inboxStatusAccentCss, inboxStatusLabel, } from "@features/inbox/utils/inboxSort"; -import { Flex, Text } from "@radix-ui/themes"; +import { UserIcon } from "@phosphor-icons/react"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; import type { SignalReport } from "@shared/types"; import { motion } from "framer-motion"; import type { KeyboardEvent, MouseEvent } from "react"; @@ -118,6 +119,20 @@ export function ReportCard({ {statusLabel} + {report.is_suggested_reviewer && ( + + + + + + )} {/* Summary is outside the title row so wrapped lines align with title text (bullet + gap), not the card edge */} diff --git a/apps/code/src/renderer/features/inbox/components/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/SignalCard.tsx index 22a0fff82..acdec85d8 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalCard.tsx @@ -1,10 +1,9 @@ import { ArrowSquareOutIcon, - BugIcon, CaretDownIcon, CaretRightIcon, - GithubLogoIcon, TagIcon, + WarningIcon, } from "@phosphor-icons/react"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import type { Signal } from "@shared/types"; @@ -12,10 +11,72 @@ import { useState } from "react"; const COLLAPSE_THRESHOLD = 300; -interface SignalCardProps { - signal: Signal; +// ── Source line labels (matching PostHog Cloud's signalCardSourceLine) ──────── + +const ERROR_TRACKING_TYPE_LABELS: Record = { + issue_created: "New issue", + issue_reopened: "Issue reopened", + issue_spiking: "Volume spike", +}; + +function signalCardSourceLine(signal: { + source_product: string; + source_type: string; +}): string { + const { source_product, source_type } = signal; + + if (source_product === "error_tracking") { + const typeLabel = + ERROR_TRACKING_TYPE_LABELS[source_type] ?? source_type.replace(/_/g, " "); + return `Error tracking · ${typeLabel}`; + } + if ( + source_product === "session_replay" && + source_type === "session_segment_cluster" + ) { + return "Session replay · Session segment cluster"; + } + if ( + source_product === "session_replay" && + source_type === "session_analysis_cluster" + ) { + return "Session replay · Session analysis cluster"; + } + if (source_product === "llm_analytics" && source_type === "evaluation") { + return "LLM analytics · Evaluation"; + } + if (source_product === "zendesk" && source_type === "ticket") { + return "Zendesk · Ticket"; + } + if (source_product === "github" && source_type === "issue") { + return "GitHub · Issue"; + } + if (source_product === "linear" && source_type === "issue") { + return "Linear · Issue"; + } + + const productLabel = source_product.replace(/_/g, " "); + const typeLabel = source_type.replace(/_/g, " "); + return `${productLabel} · ${typeLabel}`; +} + +// ── Source product color (matching Cloud's known product colors) ────────────── + +const SOURCE_PRODUCT_COLORS: Record = { + error_tracking: "var(--red-9)", + session_replay: "var(--amber-9)", + llm_analytics: "var(--purple-9)", + github: "var(--gray-11)", + linear: "var(--blue-9)", + zendesk: "var(--green-9)", +}; + +function sourceProductColor(product: string): string { + return SOURCE_PRODUCT_COLORS[product] ?? "var(--gray-9)"; } +// ── Shared utilities ───────────────────────────────────────────────────────── + interface GitHubLabelObject { name: string; color?: string; @@ -24,11 +85,26 @@ interface GitHubLabelObject { interface GitHubIssueExtra { html_url?: string; number?: number; - state?: string; labels?: string | GitHubLabelObject[]; created_at?: string; - updated_at?: string; - locked?: boolean; +} + +interface ZendeskTicketExtra { + url?: string; + priority?: string; + status?: string; + tags?: string[]; +} + +interface LlmEvalExtra { + evaluation_id?: string; + trace_id?: string; + model?: string; + provider?: string; +} + +interface ErrorTrackingExtra { + fingerprint?: string; } function resolveLabels( @@ -57,18 +133,6 @@ function resolveLabels( return []; } -function splitTitleBody(content: string): { title: string; body: string } { - const firstNewline = content.indexOf("\n"); - if (firstNewline === -1) return { title: content, body: "" }; - return { - title: content.slice(0, firstNewline).trim(), - body: content - .slice(firstNewline + 1) - .replace(/^[\n]+/, "") - .trim(), - }; -} - function truncateBody(body: string, maxLength = COLLAPSE_THRESHOLD): string { if (body.length <= maxLength) return body; const truncated = body.slice(0, maxLength); @@ -77,6 +141,73 @@ function truncateBody(body: string, maxLength = COLLAPSE_THRESHOLD): string { return `${truncated.slice(0, cutPoint)}…`; } +function parseExtra(raw: Record): Record { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } + } + return raw; +} + +// ── Type guards ────────────────────────────────────────────────────────────── + +function isGithubIssueExtra( + extra: Record, +): extra is Record & GitHubIssueExtra { + return "html_url" in extra && "number" in extra; +} + +function isZendeskTicketExtra( + extra: Record, +): extra is Record & ZendeskTicketExtra { + return "url" in extra && "priority" in extra; +} + +function isLlmEvalExtra( + extra: Record, +): extra is Record & LlmEvalExtra { + return "evaluation_id" in extra && "trace_id" in extra; +} + +function isErrorTrackingExtra( + extra: Record, +): extra is Record & ErrorTrackingExtra { + return typeof extra.fingerprint === "string"; +} + +// ── Shared components ──────────────────────────────────────────────────────── + +function SignalCardHeader({ signal }: { signal: Signal }) { + return ( + + + + {signalCardSourceLine(signal)} + + + + Weight: {signal.weight.toFixed(1)} + + + ); +} + function CollapsibleBody({ body }: { body: string }) { const [expanded, setExpanded] = useState(false); const isLong = body.length > COLLAPSE_THRESHOLD; @@ -108,154 +239,257 @@ function CollapsibleBody({ body }: { body: string }) { ); } -function parseExtra(raw: Record): GitHubIssueExtra { - if (typeof raw === "string") { - try { - return JSON.parse(raw) as GitHubIssueExtra; - } catch { - return {}; - } - } - return raw as GitHubIssueExtra; -} +// ── Source-specific cards ──────────────────────────────────────────────────── -function GitHubIssueSignalCard({ signal }: SignalCardProps) { - const extra = parseExtra(signal.extra); +function GitHubIssueSignalCard({ + signal, + extra, +}: { + signal: Signal; + extra: GitHubIssueExtra; +}) { const labels = resolveLabels(extra.labels); const issueUrl = extra.html_url ?? null; - const issueNumber = extra.number ?? null; - const { title, body } = splitTitleBody(signal.content); - - const titleContent = ( - <> - {issueNumber ? `#${issueNumber} ` : ""} - {title} - - ); return ( - + + + - - {issueUrl ? ( + + #{extra.number} + + {labels.map((label) => ( + + + {label.name} + + ))} + + {issueUrl && ( - {titleContent} + View on GitHub + - ) : ( - + {extra.created_at && ( + + Opened: {new Date(extra.created_at).toLocaleString()} + + )} + + ); +} + +function ZendeskTicketSignalCard({ + signal, + extra, +}: { + signal: Signal; + extra: ZendeskTicketExtra; +}) { + return ( + + + + + {extra.priority && ( + + Priority: {extra.priority} + + )} + {extra.status && ( + + Status: {extra.status} + + )} + {extra.tags?.map((tag) => ( + - {titleContent} - - )} - {issueUrl && ( + {tag} + + ))} + + {extra.url && ( + Open )} - - - {labels.length > 0 && ( - - - {labels.map((label) => ( - - {label.name} - - ))} - - )} - - {body && } - - - - w:{signal.weight.toFixed(2)} - - - {new Date(signal.timestamp).toLocaleString()} - - - ); } -function DefaultSignalCard({ signal }: SignalCardProps) { +function LlmEvalSignalCard({ + signal, + extra, +}: { + signal: Signal; + extra: LlmEvalExtra; +}) { return ( - + + + - - - - {signal.source_product} - - - {signal.source_type} - - + {extra.model && Model: {extra.model}} + {extra.model && extra.provider && ·} + {extra.provider && Provider: {extra.provider}} + {extra.trace_id && ( + + Trace:{" "} + {extra.trace_id.slice(0, 12)}... + + )} + + ); +} - - +function ErrorTrackingSignalCard({ + signal, + extra, +}: { + signal: Signal; + extra: ErrorTrackingExtra; +}) { + const fingerprint = extra.fingerprint ?? ""; + const fingerprintShort = + fingerprint.length > 14 ? `${fingerprint.slice(0, 14)}…` : fingerprint; - - - w:{signal.weight.toFixed(2)} - - - {new Date(signal.timestamp).toLocaleString()} - + return ( + + + + + + + + Fingerprint{" "} + + {fingerprintShort} + + + + {/* No "View issue" link in Code — error tracking lives in Cloud */} ); } -export function SignalCard({ signal }: SignalCardProps) { - if (signal.source_product === "github") { - return ; +function GenericSignalCard({ signal }: { signal: Signal }) { + return ( + + + + + {new Date(signal.timestamp).toLocaleString()} + + + ); +} + +// ── Main export ────────────────────────────────────────────────────────────── + +export function SignalCard({ signal }: { signal: Signal }) { + const extra = parseExtra(signal.extra); + + if ( + signal.source_product === "error_tracking" && + isErrorTrackingExtra(extra) + ) { + return ; + } + if (signal.source_product === "github" && isGithubIssueExtra(extra)) { + return ; + } + if (signal.source_product === "zendesk" && isZendeskTicketExtra(extra)) { + return ; + } + if (signal.source_product === "llm_analytics" && isLlmEvalExtra(extra)) { + return ; } - return ; + return ; } diff --git a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx index f7aae6e0a..07738a965 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx @@ -1,15 +1,27 @@ import { + ArrowSquareOutIcon, BrainIcon, + BugIcon, GithubLogoIcon, KanbanIcon, TicketIcon, VideoIcon, } from "@phosphor-icons/react"; -import { Box, Button, Flex, Spinner, Switch, Text } from "@radix-ui/themes"; +import { + Box, + Button, + Flex, + Link, + Spinner, + Switch, + Text, +} from "@radix-ui/themes"; +import type { Evaluation } from "@renderer/api/posthogClient"; +import { memo, useCallback } from "react"; export interface SignalSourceValues { session_replay: boolean; - llm_analytics: boolean; + error_tracking: boolean; github: boolean; linear: boolean; zendesk: boolean; @@ -27,7 +39,7 @@ interface SignalSourceToggleCardProps { loading?: boolean; } -function SignalSourceToggleCard({ +const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ icon, label, description, @@ -44,6 +56,7 @@ function SignalSourceToggleCard({ style={{ backgroundColor: "var(--color-panel-solid)", border: "1px solid var(--gray-4)", + borderRadius: "var(--radius-3)", cursor: disabled || loading ? "default" : "pointer", }} onClick={ @@ -90,11 +103,116 @@ function SignalSourceToggleCard({ ); +}); + +interface EvaluationRowProps { + evaluation: Evaluation; + onToggle: (id: string, enabled: boolean) => void; +} + +const EvaluationRow = memo(function EvaluationRow({ + evaluation, + onToggle, +}: EvaluationRowProps) { + const handleChange = useCallback( + (checked: boolean) => onToggle(evaluation.id, checked), + [onToggle, evaluation.id], + ); + + return ( + + + {evaluation.name} + + + + ); +}); + +interface EvaluationsSectionProps { + evaluations: Evaluation[]; + evaluationsUrl: string; + onToggleEvaluation: (id: string, enabled: boolean) => void; } +export const EvaluationsSection = memo(function EvaluationsSection({ + evaluations, + evaluationsUrl, + onToggleEvaluation, +}: EvaluationsSectionProps) { + return ( + + + + + + + + + LLM evaluations + + + Ongoing evaluation of how your AI features are performing based on + defined criteria + + + + + + {evaluations.length > 0 ? ( + + {evaluations.map((evaluation) => ( + + ))} + + ) : ( + + No evaluations configured yet. + + )} + + + Manage evaluations in PostHog Cloud + + + + + + ); +}); + interface SignalSourceTogglesProps { value: SignalSourceValues; - onChange: (value: SignalSourceValues) => void; + onToggle: (source: keyof SignalSourceValues, enabled: boolean) => void; disabled?: boolean; sourceStates?: Partial< Record< @@ -103,68 +221,101 @@ interface SignalSourceTogglesProps { > >; onSetup?: (source: keyof SignalSourceValues) => void; + evaluations?: Evaluation[]; + evaluationsUrl?: string; + onToggleEvaluation?: (id: string, enabled: boolean) => void; } export function SignalSourceToggles({ value, - onChange, + onToggle, disabled, sourceStates, onSetup, + evaluations, + evaluationsUrl, + onToggleEvaluation, }: SignalSourceTogglesProps) { + const toggleSessionReplay = useCallback( + (checked: boolean) => onToggle("session_replay", checked), + [onToggle], + ); + const toggleErrorTracking = useCallback( + (checked: boolean) => onToggle("error_tracking", checked), + [onToggle], + ); + const toggleGithub = useCallback( + (checked: boolean) => onToggle("github", checked), + [onToggle], + ); + const toggleLinear = useCallback( + (checked: boolean) => onToggle("linear", checked), + [onToggle], + ); + const toggleZendesk = useCallback( + (checked: boolean) => onToggle("zendesk", checked), + [onToggle], + ); + const setupGithub = useCallback(() => onSetup?.("github"), [onSetup]); + const setupLinear = useCallback(() => onSetup?.("linear"), [onSetup]); + const setupZendesk = useCallback(() => onSetup?.("zendesk"), [onSetup]); + return ( } - label="Session replay" - description="Allow PostHog to watch session recordings for you, and spot UX issues." + label="PostHog Session Replay" + description="Analyze session recordings and event data for UX issues" checked={value.session_replay} - onCheckedChange={(checked) => - onChange({ ...value, session_replay: checked }) - } + onCheckedChange={toggleSessionReplay} disabled={disabled} /> } - label="LLM analytics" - description="Allow PostHog to evaluate live LLM traces for you, and flag anomalies." - checked={value.llm_analytics} - onCheckedChange={(checked) => - onChange({ ...value, llm_analytics: checked }) - } + icon={} + label="PostHog Error Tracking" + description="Surface new issues, reopenings, and volume spikes" + checked={value.error_tracking} + onCheckedChange={toggleErrorTracking} disabled={disabled} /> + {evaluations && evaluationsUrl && onToggleEvaluation && ( + + )} } - label="GitHub" - description="Allow PostHog to read GitHub issues for you, and highlight what needs attention." + label="GitHub Issues" + description="Monitor new issues and updates" checked={value.github} - onCheckedChange={(checked) => onChange({ ...value, github: checked })} + onCheckedChange={toggleGithub} disabled={disabled} requiresSetup={sourceStates?.github?.requiresSetup} - onSetup={() => onSetup?.("github")} + onSetup={setupGithub} loading={sourceStates?.github?.loading} /> } label="Linear" - description="Allow PostHog to read Linear issues for you, and pick out priorities." + description="Monitor new issues and updates" checked={value.linear} - onCheckedChange={(checked) => onChange({ ...value, linear: checked })} + onCheckedChange={toggleLinear} disabled={disabled} requiresSetup={sourceStates?.linear?.requiresSetup} - onSetup={() => onSetup?.("linear")} + onSetup={setupLinear} loading={sourceStates?.linear?.loading} /> } label="Zendesk" - description="Allow PostHog to investigate support tickets for you, and find follow-ups." + description="Monitor incoming support tickets" checked={value.zendesk} - onCheckedChange={(checked) => onChange({ ...value, zendesk: checked })} + onCheckedChange={toggleZendesk} disabled={disabled} requiresSetup={sourceStates?.zendesk?.requiresSetup} - onSetup={() => onSetup?.("zendesk")} + onSetup={setupZendesk} loading={sourceStates?.zendesk?.loading} /> diff --git a/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx b/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx index 0b8d4d4d3..3c2a63ed2 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalsToolbar.tsx @@ -1,14 +1,22 @@ import { useInboxSignalsFilterStore } from "@features/inbox/stores/inboxSignalsFilterStore"; +import { + inboxStatusAccentCss, + inboxStatusLabel, +} from "@features/inbox/utils/inboxSort"; import { CalendarPlus, Check, Clock, FunnelSimple as FunnelSimpleIcon, + ListNumbers, MagnifyingGlass, TrendUp, } from "@phosphor-icons/react"; -import { Box, Flex, Popover, Text, TextField } from "@radix-ui/themes"; -import type { SignalReportOrderingField } from "@shared/types"; +import { Box, Flex, Popover, Text, TextField, Tooltip } from "@radix-ui/themes"; +import type { + SignalReportOrderingField, + SignalReportStatus, +} from "@shared/types"; interface SignalsToolbarProps { totalCount: number; @@ -17,16 +25,26 @@ interface SignalsToolbarProps { livePolling?: boolean; readyCount?: number; processingCount?: number; + searchDisabledReason?: string | null; } type SortOption = { label: string; - field: Extract; + field: Extract< + SignalReportOrderingField, + "priority" | "created_at" | "total_weight" + >; direction: "asc" | "desc"; icon: React.ReactNode; }; const sortOptions: SortOption[] = [ + { + label: "Priority", + field: "priority", + direction: "asc", + icon: , + }, { label: "Strongest signal", field: "total_weight", @@ -47,6 +65,14 @@ const sortOptions: SortOption[] = [ }, ]; +const FILTERABLE_STATUSES: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", +]; + export function SignalsToolbar({ totalCount, filteredCount, @@ -54,12 +80,15 @@ export function SignalsToolbar({ livePolling = false, readyCount, processingCount = 0, + searchDisabledReason, }: 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 countLabel = isSearchActive ? `${filteredCount} of ${totalCount}` @@ -103,31 +132,38 @@ export function SignalsToolbar({ ) : null} - - setSearchQuery(e.target.value)} - className="text-[12px]" - > - - - - + ); } -function SortMenu({ +function FilterSortMenu({ sortField, sortDirection, onSort, + statusFilter, + onToggleStatus, }: { sortField: string; sortDirection: string; @@ -135,6 +171,8 @@ function SortMenu({ field: SortOption["field"], direction: SortOption["direction"], ) => void; + statusFilter: SignalReportStatus[]; + onToggleStatus: (status: SignalReportStatus) => void; }) { 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"; @@ -144,7 +182,7 @@ function SortMenu({ + ); + })} + + diff --git a/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts b/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts new file mode 100644 index 000000000..dcd207e93 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/hooks/useEvaluations.ts @@ -0,0 +1,19 @@ +import { useAuthStore } from "@features/auth/stores/authStore"; +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; +import type { Evaluation } from "@renderer/api/posthogClient"; + +const POLL_INTERVAL_MS = 5_000; + +export function useEvaluations() { + const projectId = useAuthStore((s) => s.projectId); + return useAuthenticatedQuery( + ["evaluations", projectId], + (client) => + projectId ? client.listEvaluations(projectId) : Promise.resolve([]), + { + enabled: !!projectId, + staleTime: POLL_INTERVAL_MS, + refetchInterval: POLL_INTERVAL_MS, + }, + ); +} diff --git a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts index 34fc79b47..558e57947 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -1,32 +1,44 @@ import { useAuthStore } from "@features/auth/stores/authStore"; import type { SignalSourceValues } from "@features/inbox/components/SignalSourceToggles"; -import { useAuthenticatedMutation } from "@hooks/useAuthenticatedMutation"; +import type { + Evaluation, + SignalSourceConfig, +} from "@renderer/api/posthogClient"; +import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useMemo, useRef, useState } from "react"; import { toast } from "sonner"; +import { useEvaluations } from "./useEvaluations"; import { useExternalDataSources } from "./useExternalDataSources"; import { useSignalSourceConfigs } from "./useSignalSourceConfigs"; -type SourceProduct = - | "session_replay" - | "llm_analytics" - | "github" - | "linear" - | "zendesk"; -type SourceType = - | "session_analysis_cluster" - | "evaluation" - | "issue" - | "ticket"; - -const SOURCE_TYPE_MAP: Record = { +type SourceProduct = SignalSourceConfig["source_product"]; +type SourceType = SignalSourceConfig["source_type"]; + +const SOURCE_TYPE_MAP: Record< + Exclude, + SourceType +> = { session_replay: "session_analysis_cluster", - llm_analytics: "evaluation", github: "issue", linear: "issue", zendesk: "ticket", }; +const ERROR_TRACKING_SOURCE_TYPES: SourceType[] = [ + "issue_created", + "issue_reopened", + "issue_spiking", +]; + +const SOURCE_LABELS: Record = { + session_replay: "Session replay", + error_tracking: "Error tracking", + github: "GitHub Issues", + linear: "Linear Issues", + zendesk: "Zendesk Tickets", +}; + const DATA_WAREHOUSE_SOURCES: Record< string, { dwSourceType: string; requiredTable: string } @@ -36,23 +48,61 @@ const DATA_WAREHOUSE_SOURCES: Record< zendesk: { dwSourceType: "Zendesk", requiredTable: "tickets" }, }; -const ALL_SOURCE_PRODUCTS: SourceProduct[] = [ +const ALL_SOURCE_PRODUCTS: (keyof SignalSourceValues)[] = [ "session_replay", - "llm_analytics", + "error_tracking", "github", "linear", "zendesk", ]; +function computeValues( + configs: SignalSourceConfig[] | undefined, +): SignalSourceValues { + const result: SignalSourceValues = { + session_replay: false, + error_tracking: false, + github: false, + linear: false, + zendesk: false, + }; + if (!configs?.length) return result; + for (const product of ALL_SOURCE_PRODUCTS) { + if (product === "error_tracking") { + result.error_tracking = ERROR_TRACKING_SOURCE_TYPES.every((st) => + configs.some( + (c) => + c.source_product === "error_tracking" && + c.source_type === st && + c.enabled, + ), + ); + } else { + result[product] = configs.some( + (c) => c.source_product === product && c.enabled, + ); + } + } + return result; +} + export function useSignalSourceManager() { const projectId = useAuthStore((s) => s.projectId); const client = useAuthStore((s) => s.client); + const cloudRegion = useAuthStore((s) => s.cloudRegion); const queryClient = useQueryClient(); const { data: configs, isLoading: configsLoading } = useSignalSourceConfigs(); const { data: externalSources, isLoading: sourcesLoading } = useExternalDataSources(); - const savingRef = useRef(false); - const [optimistic, setOptimistic] = useState(null); + const { data: evaluations } = useEvaluations(); + + // Optimistic overrides keyed by source product — only sources actively being + // toggled get an entry, so unrelated sources never see a prop change. + const [optimistic, setOptimistic] = useState< + Partial> + >({}); + const pendingRef = useRef(new Set()); + const [setupSource, setSetupSource] = useState< "github" | "linear" | "zendesk" | null >(null); @@ -74,23 +124,16 @@ export function useSignalSourceManager() { [externalSources], ); - const serverValues = useMemo(() => { - const result: SignalSourceValues = { - session_replay: false, - llm_analytics: false, - github: false, - linear: false, - zendesk: false, - }; - for (const product of ALL_SOURCE_PRODUCTS) { - result[product] = !!configs?.some( - (c) => c.source_product === product && c.enabled, - ); - } - return result; - }, [configs]); + const serverValues = useMemo( + () => computeValues(configs), + [configs], + ); - const displayValues = optimistic ?? serverValues; + // Merge: optimistic overrides take precedence over server values. + const displayValues = useMemo(() => { + if (Object.keys(optimistic).length === 0) return serverValues; + return { ...serverValues, ...optimistic }; + }, [serverValues, optimistic]); const sourceStates = useMemo(() => { const states: Partial< @@ -110,29 +153,48 @@ export function useSignalSourceManager() { return states; }, [findExternalSource, serverValues, loadingSources]); - const createConfig = useAuthenticatedMutation( - ( - apiClient, - options: { - source_product: SourceProduct; - source_type: SourceType; - }, - ) => - projectId - ? apiClient.createSignalSourceConfig(projectId, { - ...options, - enabled: true, - }) - : Promise.reject(new Error("No project selected")), - ); + const evaluationsUrl = useMemo(() => { + if (!cloudRegion) return ""; + return `${getCloudUrlFromRegion(cloudRegion)}/llm-analytics/evaluations`; + }, [cloudRegion]); - const updateConfig = useAuthenticatedMutation( - (apiClient, options: { configId: string; enabled: boolean }) => - projectId - ? apiClient.updateSignalSourceConfig(projectId, options.configId, { - enabled: options.enabled, - }) - : Promise.reject(new Error("No project selected")), + // Optimistic evaluation state: map of evaluation ID to overridden enabled value + const [optimisticEvals, setOptimisticEvals] = useState< + Record + >({}); + + const displayEvaluations = useMemo(() => { + if (!evaluations) return []; + if (Object.keys(optimisticEvals).length === 0) return evaluations; + return evaluations.map((e) => + e.id in optimisticEvals ? { ...e, enabled: optimisticEvals[e.id] } : e, + ); + }, [evaluations, optimisticEvals]); + + const handleToggleEvaluation = useCallback( + async (evaluationId: string, enabled: boolean) => { + if (!client || !projectId) return; + + setOptimisticEvals((prev) => ({ ...prev, [evaluationId]: enabled })); + + try { + await client.updateEvaluation(projectId, evaluationId, { enabled }); + await queryClient.invalidateQueries({ queryKey: ["evaluations"] }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to toggle evaluation"; + toast.error(message); + } finally { + setOptimisticEvals((prev) => { + const next = { ...prev }; + delete next[evaluationId]; + return next; + }); + } + }, + [client, projectId, queryClient], ); const ensureRequiredTableSyncing = useCallback( @@ -182,148 +244,165 @@ export function useSignalSourceManager() { } }, []); - const handleSetupComplete = useCallback(async () => { - const completedSource = setupSource; - setSetupSource(null); - - // Create the signal source config for the source that was just connected - if (completedSource) { - const existing = configs?.find( - (c) => c.source_product === completedSource, - ); - if (!existing) { - try { - await createConfig.mutateAsync({ - source_product: completedSource, - source_type: SOURCE_TYPE_MAP[completedSource], - }); - } catch { - toast.error( - "Data source connected, but failed to enable signal source. Try toggling it on.", - ); + const invalidateAfterToggle = useCallback(async () => { + await Promise.all([ + queryClient.invalidateQueries({ + queryKey: ["signals", "source-configs"], + }), + queryClient.invalidateQueries({ + queryKey: ["inbox", "signal-reports"], + }), + ]); + }, [queryClient]); + + // Toggle a single source product. Calls the API directly (no react-query + // mutation tracking) so intermediate loading/success states don't cause + // cascading re-renders. + const handleToggle = useCallback( + async (product: keyof SignalSourceValues, enabled: boolean) => { + if (!client || !projectId) return; + if (pendingRef.current.has(product)) return; + + // Warehouse sources without a connected external data source need setup first + if (enabled && product in DATA_WAREHOUSE_SOURCES) { + const hasExternalSource = !!findExternalSource(product); + if (!hasExternalSource) { + setSetupSource(product as "github" | "linear" | "zendesk"); + return; } - } else if (!existing.enabled) { + + setLoadingSources((prev) => ({ ...prev, [product]: true })); try { - await updateConfig.mutateAsync({ - configId: existing.id, - enabled: true, - }); - } catch { - toast.error( - "Data source connected, but failed to enable signal source. Try toggling it on.", - ); + await ensureRequiredTableSyncing(product); + } finally { + setLoadingSources((prev) => ({ ...prev, [product]: false })); } } - } - - await queryClient.invalidateQueries({ - queryKey: ["external-data-sources"], - }); - await queryClient.invalidateQueries({ - queryKey: ["signals", "source-configs"], - }); - await queryClient.invalidateQueries({ - queryKey: ["inbox", "signal-reports"], - }); - }, [queryClient, setupSource, configs, createConfig, updateConfig]); - const handleSetupCancel = useCallback(() => { - setSetupSource(null); - }, []); + // Optimistic update — only touches this one key + pendingRef.current.add(product); + setOptimistic((prev) => ({ ...prev, [product]: enabled })); - const handleChange = useCallback( - async (values: SignalSourceValues) => { - if (savingRef.current) return; + const label = SOURCE_LABELS[product]; - setOptimistic(values); try { - const operations: Array<() => Promise> = []; - - for (const product of ALL_SOURCE_PRODUCTS) { - const wanted = values[product]; - const current = serverValues[product]; - if (wanted === current) continue; - - // If enabling a warehouse source without an external data source, open setup - if (wanted && product in DATA_WAREHOUSE_SOURCES) { - const hasExternalSource = !!findExternalSource(product); - if (!hasExternalSource) { - setSetupSource(product as "github" | "linear" | "zendesk"); - return; - } - - // Ensure required table is syncing - setLoadingSources((prev) => ({ ...prev, [product]: true })); - try { - await ensureRequiredTableSyncing(product); - } finally { - setLoadingSources((prev) => ({ ...prev, [product]: false })); + if (product === "error_tracking") { + for (const sourceType of ERROR_TRACKING_SOURCE_TYPES) { + const existing = configs?.find( + (c) => + c.source_product === "error_tracking" && + c.source_type === sourceType, + ); + if (existing) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled, + }); + } else if (enabled) { + await client.createSignalSourceConfig(projectId, { + source_product: "error_tracking", + source_type: sourceType, + enabled: true, + }); } } - + } else { const existing = configs?.find((c) => c.source_product === product); - - if (wanted && !existing) { - operations.push(() => - createConfig.mutateAsync({ - source_product: product, - source_type: SOURCE_TYPE_MAP[product], - }), - ); - } else if (existing) { - operations.push(() => - updateConfig.mutateAsync({ - configId: existing.id, - enabled: wanted, - }), - ); + if (existing) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled, + }); + } else if (enabled) { + await client.createSignalSourceConfig(projectId, { + source_product: product, + source_type: + SOURCE_TYPE_MAP[ + product as Exclude< + SourceProduct, + "error_tracking" | "llm_analytics" + > + ], + enabled: true, + }); } } - if (operations.length === 0) { - return; - } - - savingRef.current = true; - const results = await Promise.allSettled(operations.map((op) => op())); - const failed = results.filter((r) => r.status === "rejected"); - if (failed.length > 0) { - toast.error("Failed to update signal sources. Please try again."); - return; - } - - await queryClient.invalidateQueries({ - queryKey: ["signals", "source-configs"], - }); - await queryClient.invalidateQueries({ - queryKey: ["inbox", "signal-reports"], - }); - } catch { - toast.error("Failed to update signal sources. Please try again."); + await invalidateAfterToggle(); + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : `Failed to toggle ${label}`; + toast.error(message); } finally { - savingRef.current = false; - setOptimistic(null); + pendingRef.current.delete(product); + setOptimistic((prev) => { + const next = { ...prev }; + delete next[product]; + return next; + }); } }, [ - serverValues, + client, + projectId, configs, - createConfig, - updateConfig, - queryClient, findExternalSource, ensureRequiredTableSyncing, + invalidateAfterToggle, ], ); + const handleSetupComplete = useCallback(async () => { + const completedSource = setupSource; + setSetupSource(null); + + if (completedSource && client && projectId) { + const existing = configs?.find( + (c) => c.source_product === completedSource, + ); + try { + if (!existing) { + await client.createSignalSourceConfig(projectId, { + source_product: completedSource, + source_type: SOURCE_TYPE_MAP[completedSource], + enabled: true, + }); + } else if (!existing.enabled) { + await client.updateSignalSourceConfig(projectId, existing.id, { + enabled: true, + }); + } + } catch { + toast.error( + "Data source connected, but failed to enable signal source. Try toggling it on.", + ); + } + } + + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["external-data-sources"] }), + queryClient.invalidateQueries({ + queryKey: ["signals", "source-configs"], + }), + queryClient.invalidateQueries({ + queryKey: ["inbox", "signal-reports"], + }), + ]); + }, [queryClient, setupSource, configs, client, projectId]); + + const handleSetupCancel = useCallback(() => { + setSetupSource(null); + }, []); + return { displayValues, sourceStates, setupSource, isLoading, - handleChange, + handleToggle, handleSetup, handleSetupComplete, handleSetupCancel, + evaluations: displayEvaluations, + evaluationsUrl, + handleToggleEvaluation, }; } diff --git a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts index 4aae45062..a186b420f 100644 --- a/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts +++ b/apps/code/src/renderer/features/inbox/stores/inboxSignalsFilterStore.ts @@ -1,23 +1,37 @@ -import type { SignalReportOrderingField } from "@shared/types"; +import type { + SignalReportOrderingField, + SignalReportStatus, +} from "@shared/types"; import { create } from "zustand"; import { persist } from "zustand/middleware"; type SignalSortField = Extract< SignalReportOrderingField, - "created_at" | "total_weight" + "priority" | "created_at" | "total_weight" >; type SignalSortDirection = "asc" | "desc"; +const DEFAULT_STATUS_FILTER: SignalReportStatus[] = [ + "ready", + "pending_input", + "in_progress", + "candidate", + "potential", +]; + interface InboxSignalsFilterState { sortField: SignalSortField; sortDirection: SignalSortDirection; searchQuery: string; + statusFilter: SignalReportStatus[]; } interface InboxSignalsFilterActions { setSort: (field: SignalSortField, direction: SignalSortDirection) => void; setSearchQuery: (query: string) => void; + setStatusFilter: (statuses: SignalReportStatus[]) => void; + toggleStatus: (status: SignalReportStatus) => void; } type InboxSignalsFilterStore = InboxSignalsFilterState & @@ -26,17 +40,28 @@ type InboxSignalsFilterStore = InboxSignalsFilterState & export const useInboxSignalsFilterStore = create()( persist( (set) => ({ - sortField: "total_weight", - sortDirection: "desc", + sortField: "priority", + sortDirection: "asc", searchQuery: "", + statusFilter: DEFAULT_STATUS_FILTER, setSort: (sortField, sortDirection) => set({ sortField, sortDirection }), setSearchQuery: (searchQuery) => set({ searchQuery }), + setStatusFilter: (statusFilter) => set({ statusFilter }), + toggleStatus: (status) => + set((state) => { + const current = state.statusFilter; + const next = current.includes(status) + ? current.filter((s) => s !== status) + : [...current, status]; + return { statusFilter: next.length > 0 ? next : current }; + }), }), { name: "inbox-signals-filter-storage", partialize: (state) => ({ sortField: state.sortField, sortDirection: state.sortDirection, + statusFilter: state.statusFilter, }), }, ), diff --git a/apps/code/src/renderer/features/inbox/utils/filterReports.ts b/apps/code/src/renderer/features/inbox/utils/filterReports.ts index 56c74e6a5..43331f2a8 100644 --- a/apps/code/src/renderer/features/inbox/utils/filterReports.ts +++ b/apps/code/src/renderer/features/inbox/utils/filterReports.ts @@ -1,4 +1,8 @@ -import type { SignalReport, SignalReportOrderingField } from "@shared/types"; +import type { + SignalReport, + SignalReportOrderingField, + SignalReportStatus, +} from "@shared/types"; export function filterReportsBySearch( reports: SignalReport[], @@ -16,13 +20,22 @@ export function filterReportsBySearch( } /** - * Comma-separated `ordering` for the signal report list API: semantic `status` rank - * then the toolbar field (matches default inbox UX). + * Build a comma-separated status filter string for the API from an array of statuses. + */ +export function buildStatusFilterParam(statuses: SignalReportStatus[]): string { + return statuses.join(","); +} + +/** + * Comma-separated `ordering` for the signal report list API: + * 1. Status rank (ready first — semantic server-side rank, always applied) + * 2. Suggested reviewer (current user's reports first) + * 3. Toolbar-selected field (priority, total_weight, created_at, etc.) */ export function buildSignalReportListOrdering( field: SignalReportOrderingField, direction: "asc" | "desc", ): string { - const secondary = direction === "desc" ? `-${field}` : field; - return `status,${secondary}`; + const fieldKey = direction === "desc" ? `-${field}` : field; + return `status,-is_suggested_reviewer,${fieldKey}`; } diff --git a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx index 2b4bfae9b..a9139ad3c 100644 --- a/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx +++ b/apps/code/src/renderer/features/onboarding/components/SignalsStep.tsx @@ -19,15 +19,18 @@ export function SignalsStep({ onNext, onBack }: SignalsStepProps) { sourceStates, setupSource, isLoading, - handleChange, + handleToggle, handleSetup, handleSetupComplete, handleSetupCancel, + evaluations, + evaluationsUrl, + handleToggleEvaluation, } = useSignalSourceManager(); const anyEnabled = displayValues.session_replay || - displayValues.llm_analytics || + displayValues.error_tracking || displayValues.github || displayValues.linear || displayValues.zendesk; @@ -96,10 +99,17 @@ export function SignalsStep({ onNext, onBack }: SignalsStepProps) { ) : ( void handleChange(v)} + onToggle={(source, enabled) => + void handleToggle(source, enabled) + } disabled={isLoading} sourceStates={sourceStates} onSetup={handleSetup} + evaluations={evaluations} + evaluationsUrl={evaluationsUrl} + onToggleEvaluation={(id, enabled) => + void handleToggleEvaluation(id, enabled) + } /> )} diff --git a/apps/code/src/renderer/features/onboarding/hooks/useTutorialTour.ts b/apps/code/src/renderer/features/onboarding/hooks/useTutorialTour.ts index e231d08f8..8bd42efbf 100644 --- a/apps/code/src/renderer/features/onboarding/hooks/useTutorialTour.ts +++ b/apps/code/src/renderer/features/onboarding/hooks/useTutorialTour.ts @@ -66,10 +66,6 @@ export function useTutorialTour() { configs?.some( (c) => c.source_product === "session_replay" && c.enabled, ) ?? true, - llm_analytics: - configs?.some( - (c) => c.source_product === "llm_analytics" && c.enabled, - ) ?? false, github: configs?.some((c) => c.source_product === "github" && c.enabled) ?? false, @@ -79,6 +75,10 @@ export function useTutorialTour() { zendesk: configs?.some((c) => c.source_product === "zendesk" && c.enabled) ?? false, + error_tracking: + configs?.some( + (c) => c.source_product === "error_tracking" && c.enabled, + ) ?? false, }), [configs], ); diff --git a/apps/code/src/renderer/features/onboarding/utils/generateInstrumentationPrompt.ts b/apps/code/src/renderer/features/onboarding/utils/generateInstrumentationPrompt.ts index 892f7da5e..406b5cab6 100644 --- a/apps/code/src/renderer/features/onboarding/utils/generateInstrumentationPrompt.ts +++ b/apps/code/src/renderer/features/onboarding/utils/generateInstrumentationPrompt.ts @@ -13,13 +13,7 @@ export function generateInstrumentationPrompt( ); } - if (signals.llm_analytics) { - parts.push( - "Set up LLM analytics by integrating PostHog's LLM tracing. Add the appropriate PostHog LLM wrapper for the LLM framework used in this project (e.g., OpenAI, Anthropic, LangChain). Ensure LLM calls are automatically traced.", - ); - } - - if (!signals.session_replay && !signals.llm_analytics) { + if (!signals.session_replay) { parts.push( "Check if the PostHog SDK is installed. If not, install it and initialize it with the project's API key. Set up basic event tracking.", ); diff --git a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx index 855e7f60a..83187bb02 100644 --- a/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx +++ b/apps/code/src/renderer/features/settings/components/sections/SignalSourcesSettings.tsx @@ -9,10 +9,13 @@ export function SignalSourcesSettings() { sourceStates, setupSource, isLoading, - handleChange, + handleToggle, handleSetup, handleSetupComplete, handleSetupCancel, + evaluations, + evaluationsUrl, + handleToggleEvaluation, } = useSignalSourceManager(); if (isLoading) { @@ -39,9 +42,14 @@ export function SignalSourcesSettings() { ) : ( void handleChange(v)} + onToggle={(source, enabled) => void handleToggle(source, enabled)} sourceStates={sourceStates} onSetup={handleSetup} + evaluations={evaluations} + evaluationsUrl={evaluationsUrl} + onToggleEvaluation={(id, enabled) => + void handleToggleEvaluation(id, enabled) + } /> )} diff --git a/apps/code/src/shared/types.ts b/apps/code/src/shared/types.ts index 4abc873dd..7e973e670 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -189,6 +189,8 @@ export interface SignalReport { artefact_count: number; /** P0–P4 from actionability judgment when the report is researched */ priority?: SignalReportPriority | null; + /** Whether the current user is a suggested reviewer for this report (server-annotated). */ + is_suggested_reviewer?: boolean; } export interface SignalReportArtefactContent { @@ -207,6 +209,34 @@ export interface SignalReportArtefact { created_at: string; } +/** Artefact with `type: "suggested_reviewers"` — content is an enriched reviewer list. */ +export interface SuggestedReviewersArtefact { + id: string; + type: "suggested_reviewers"; + content: SuggestedReviewer[]; + created_at: string; +} + +export interface SuggestedReviewerCommit { + sha: string; + url: string; + reason: string; +} + +export interface SuggestedReviewerUser { + id: number; + uuid: string; + email: string; + first_name: string; +} + +export interface SuggestedReviewer { + github_login: string; + github_name: string | null; + relevant_commits: SuggestedReviewerCommit[]; + user: SuggestedReviewerUser | null; +} + interface MatchedSignalMetadata { parent_signal_id: string; match_query: string; @@ -243,7 +273,7 @@ export interface SignalReportSignalsResponse { } export interface SignalReportArtefactsResponse { - results: SignalReportArtefact[]; + results: (SignalReportArtefact | SuggestedReviewersArtefact)[]; count: number; unavailableReason?: | "forbidden" @@ -253,6 +283,7 @@ export interface SignalReportArtefactsResponse { } export type SignalReportOrderingField = + | "priority" | "signal_count" | "total_weight" | "created_at" @@ -261,7 +292,7 @@ export type SignalReportOrderingField = export interface SignalReportsQueryParams { limit?: number; offset?: number; - status?: CommaSeparatedSignalReportStatuses; + status?: CommaSeparatedSignalReportStatuses | string; /** * Comma-separated sort keys (prefix `-` for descending). `status` is semantic stage * rank (not lexicographic `status` column order). Also: `signal_count`, `total_weight`,