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/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 a3a5698a3..bfba77736 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -18,7 +18,7 @@ import { } from "@features/inbox/utils/filterReports"; 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"; @@ -35,10 +35,12 @@ import { Badge, Box, Button, + Dialog, Flex, ScrollArea, Select, Text, + Tooltip, } from "@radix-ui/themes"; import graphsHog from "@renderer/assets/images/graphs-hog.png"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; @@ -124,7 +126,7 @@ export function InboxSignalsTab() { 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"); @@ -324,81 +326,126 @@ export function InboxSignalsTab() { ); } + const sourcesDialog = ( + + + + + Signal sources + + + + + + + + {hasSignalSources ? ( + + + + ) : ( + + + + )} + + + + ); + if (allReports.length === 0) { if (!hasSignalSources) { 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. + Welcome to your Inbox
- - - - - Ready-to-run fixes for real user problems. + + + Background analysis of your data — while you sleep. + +
+ Session recordings watched automatically. Issues, tickets, and + evals analyzed around the clock.
-
- Each report includes evidence and impact numbers — just execute - the prompt in your agent. -
-
- + + + + + Ready-to-run fixes for real user problems. + +
+ Each report includes evidence and impact numbers — just + execute the prompt in your agent. +
+
+ + +
-
+ {sourcesDialog} + ); } - return ; + return ( + <> + setSourcesDialogOpen(true)} + /> + {sourcesDialog} + + ); } return ( diff --git a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx b/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx index 115835673..5665ef5f8 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxWarmingUpState.tsx @@ -1,5 +1,4 @@ import { useSignalSourceConfigs } from "@features/inbox/hooks/useSignalSourceConfigs"; -import { useSettingsDialogStore } from "@features/settings/stores/settingsDialogStore"; import { BugIcon, GithubLogoIcon, @@ -8,9 +7,9 @@ import { TicketIcon, VideoIcon, } from "@phosphor-icons/react"; -import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Button, Flex, Text } 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"][] = [ @@ -22,7 +21,7 @@ const SOURCE_DISPLAY_ORDER: SignalSourceConfig["source_product"][] = [ ]; 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 ; @@ -51,17 +50,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 +80,53 @@ 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/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 5e4c499e2..07738a965 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalSourceToggles.tsx @@ -56,6 +56,7 @@ const SignalSourceToggleCard = memo(function SignalSourceToggleCard({ style={{ backgroundColor: "var(--color-panel-solid)", border: "1px solid var(--gray-4)", + borderRadius: "var(--radius-3)", cursor: disabled || loading ? "default" : "pointer", }} onClick={ @@ -157,6 +158,7 @@ export const EvaluationsSection = memo(function EvaluationsSection({ style={{ backgroundColor: "var(--color-panel-solid)", border: "1px solid var(--gray-4)", + borderRadius: "var(--radius-3)", }} >