diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index ca863b679..4a6793f5e 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1,6 +1,9 @@ import type { + ActionabilityJudgmentArtefact, + PriorityJudgmentArtefact, SandboxEnvironment, SandboxEnvironmentInput, + SignalFindingArtefact, SignalReportArtefact, SignalReportArtefactsResponse, SignalReportSignalsResponse, @@ -20,6 +23,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 +32,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; @@ -60,13 +73,132 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } +const PRIORITY_VALUES = new Set(["P0", "P1", "P2", "P3", "P4"]); + +function normalizePriorityJudgmentArtefact( + value: Record, +): PriorityJudgmentArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + const priority = optionalString(contentValue.priority); + if (!priority || !PRIORITY_VALUES.has(priority)) return null; + + return { + id, + type: "priority_judgment", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + explanation: optionalString(contentValue.explanation) ?? "", + priority: priority as PriorityJudgmentArtefact["content"]["priority"], + }, + }; +} + +const ACTIONABILITY_VALUES = new Set([ + "immediately_actionable", + "requires_human_input", + "not_actionable", +]); + +function normalizeActionabilityJudgmentArtefact( + value: Record, +): ActionabilityJudgmentArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + // Support both agentic ("actionability") and legacy ("choice") field names + const actionability = + optionalString(contentValue.actionability) ?? + optionalString(contentValue.choice); + if (!actionability || !ACTIONABILITY_VALUES.has(actionability)) return null; + + return { + id, + type: "actionability_judgment", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + explanation: optionalString(contentValue.explanation) ?? "", + actionability: + actionability as ActionabilityJudgmentArtefact["content"]["actionability"], + already_addressed: + typeof contentValue.already_addressed === "boolean" + ? contentValue.already_addressed + : false, + }, + }; +} + +function normalizeSignalFindingArtefact( + value: Record, +): SignalFindingArtefact | null { + const id = optionalString(value.id); + if (!id) return null; + + const contentValue = isObjectRecord(value.content) ? value.content : null; + if (!contentValue) return null; + + const signalId = optionalString(contentValue.signal_id); + if (!signalId) return null; + + return { + id, + type: "signal_finding", + created_at: optionalString(value.created_at) ?? new Date(0).toISOString(), + content: { + signal_id: signalId, + relevant_code_paths: Array.isArray(contentValue.relevant_code_paths) + ? contentValue.relevant_code_paths.filter( + (p: unknown): p is string => typeof p === "string", + ) + : [], + relevant_commit_hashes: isObjectRecord( + contentValue.relevant_commit_hashes, + ) + ? Object.fromEntries( + Object.entries(contentValue.relevant_commit_hashes).filter( + (e): e is [string, string] => typeof e[1] === "string", + ), + ) + : {}, + data_queried: optionalString(contentValue.data_queried) ?? "", + verified: + typeof contentValue.verified === "boolean" + ? contentValue.verified + : false, + }, + }; +} + function normalizeSignalReportArtefact( value: unknown, -): SignalReportArtefact | null { +): + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | null { if (!isObjectRecord(value)) { return null; } + const type = optionalString(value.type); + if (type === "signal_finding") { + return normalizeSignalFindingArtefact(value); + } + if (type === "actionability_judgment") { + return normalizeActionabilityJudgmentArtefact(value); + } + if (type === "priority_judgment") { + return normalizePriorityJudgmentArtefact(value); + } + const id = optionalString(value.id); if (!id) { return null; @@ -115,7 +247,15 @@ function parseSignalReportArtefactsPayload( const results = rawResults .map(normalizeSignalReportArtefact) - .filter((artefact): artefact is SignalReportArtefact => artefact !== null); + .filter( + ( + artefact, + ): artefact is + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact => artefact !== null, + ); const count = typeof payload?.count === "number" ? payload.count : results.length; @@ -223,17 +363,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 +418,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/features/inbox/components/DataSourceSetup.tsx b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx index 9d365ee4f..fd2178ae0 100644 --- a/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx +++ b/apps/code/src/renderer/features/inbox/components/DataSourceSetup.tsx @@ -52,13 +52,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 = useAuthStateValue((state) => state.projectId); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const client = useAuthenticatedClient(); 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(() => { @@ -67,6 +95,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; @@ -97,10 +166,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 2bb2a12f2..6b826f1f8 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -26,13 +26,15 @@ import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, + CaretDownIcon, + CaretRightIcon, ClockIcon, Cloud as CloudIcon, + WarningIcon, XIcon, } from "@phosphor-icons/react"; import { AlertDialog, - Badge, Box, Button, Flex, @@ -42,17 +44,30 @@ import { } from "@radix-ui/themes"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { + ActionabilityJudgmentArtefact, + ActionabilityJudgmentContent, + PriorityJudgmentArtefact, + SignalFindingArtefact, + SignalReportArtefact, SignalReportArtefactsResponse, SignalReportsQueryParams, } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; import { useRendererWindowFocusStore } from "@stores/rendererWindowFocusStore"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + type ReactNode, + 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 { SignalReportActionabilityBadge } from "./SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "./SignalReportPriorityBadge"; import { SignalReportSummaryMarkdown } from "./SignalReportSummaryMarkdown"; import { SignalsToolbar } from "./SignalsToolbar"; @@ -74,6 +89,58 @@ function getArtefactsUnavailableMessage( } } +function DetailRow({ + label, + value, + explanation, +}: { + label: string; + value: ReactNode; + explanation?: string | null; +}) { + const [expanded, setExpanded] = useState(false); + const hasExplanation = !!explanation; + + return ( + + + + {label} + + {value} + {hasExplanation && ( + + )} + + {expanded && explanation && ( + + {explanation} + + )} + + ); +} + function LoadMoreTrigger({ hasNextPage, isFetchingNextPage, @@ -201,7 +268,37 @@ 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 === "video_segment", + ); + const signalFindings = useMemo(() => { + const map = new Map(); + for (const a of allArtefacts) { + if (a.type === "signal_finding") { + const finding = a as SignalFindingArtefact; + map.set(finding.content.signal_id, finding.content); + } + } + return map; + }, [allArtefacts]); + const actionabilityJudgment = + useMemo((): ActionabilityJudgmentContent | null => { + for (const a of allArtefacts) { + if (a.type === "actionability_judgment") { + return (a as ActionabilityJudgmentArtefact).content; + } + } + return null; + }, [allArtefacts]); + const priorityExplanation = useMemo((): string | null => { + for (const a of allArtefacts) { + if (a.type === "priority_judgment") { + return (a as PriorityJudgmentArtefact).content.explanation || null; + } + } + return null; + }, [allArtefacts]); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; const showArtefactsUnavailable = !artefactsQuery.isLoading && @@ -485,17 +582,63 @@ export function InboxSignalsTab() { fallback="No summary available." variant="detail" /> - - - - {selectedReport.signal_count} occurrences - - - {selectedReport.relevant_user_count ?? 0} affected users - - + {(selectedReport.actionability || selectedReport.priority) && ( + + {selectedReport.priority && ( + + } + explanation={priorityExplanation} + /> + )} + {selectedReport.actionability && ( + + } + explanation={actionabilityJudgment?.explanation} + /> + )} + + )} + + {(selectedReport.already_addressed ?? + actionabilityJudgment?.already_addressed) && ( + + + + This issue may already be addressed in recent code + changes. + + + )} {signals.length > 0 && ( @@ -509,7 +652,11 @@ export function InboxSignalsTab() { {signals.map((signal) => ( - + ))} @@ -543,7 +690,7 @@ export function InboxSignalsTab() { !showArtefactsUnavailable && visibleArtefacts.length === 0 && ( - No artefacts were returned for this signal. + No session segments available for this report. )} diff --git a/apps/code/src/renderer/features/inbox/components/ReportCard.tsx b/apps/code/src/renderer/features/inbox/components/ReportCard.tsx index bf018b8f3..3bfb9239b 100644 --- a/apps/code/src/renderer/features/inbox/components/ReportCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/ReportCard.tsx @@ -1,3 +1,4 @@ +import { SignalReportActionabilityBadge } from "@features/inbox/components/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "@features/inbox/components/SignalReportPriorityBadge"; import { SignalReportSummaryMarkdown } from "@features/inbox/components/SignalReportSummaryMarkdown"; import { @@ -118,6 +119,9 @@ export function ReportCard({ {statusLabel} + {/* 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..804c84f86 100644 --- a/apps/code/src/renderer/features/inbox/components/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/SignalCard.tsx @@ -1,21 +1,84 @@ import { ArrowSquareOutIcon, - BugIcon, CaretDownIcon, CaretRightIcon, - GithubLogoIcon, + CheckCircleIcon, + QuestionIcon, TagIcon, + WarningIcon, } from "@phosphor-icons/react"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import type { Signal } from "@shared/types"; +import type { Signal, SignalFindingContent } from "@shared/types"; 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 +87,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 +135,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 +143,101 @@ 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 VerificationBadge({ verified }: { verified: boolean }) { + return ( + + {verified ? ( + + ) : ( + + )} + {verified ? "Verified" : "Unverified"} + + ); +} + +function SignalCardHeader({ + signal, + verified, +}: { + signal: Signal; + verified?: boolean; +}) { + return ( + + + + {signalCardSourceLine(signal)} + + + {verified !== undefined && } + + Weight: {signal.weight.toFixed(1)} + + + ); +} + function CollapsibleBody({ body }: { body: string }) { const [expanded, setExpanded] = useState(false); const isLong = body.length > COLLAPSE_THRESHOLD; @@ -108,154 +269,418 @@ 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, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: GitHubIssueExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { 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, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: ZendeskTicketExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + 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 LlmEvalSignalCard({ + signal, + extra, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: LlmEvalExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + return ( + + + + + {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 DefaultSignalCard({ signal }: SignalCardProps) { +function ErrorTrackingSignalCard({ + signal, + extra, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: ErrorTrackingExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + const fingerprint = extra.fingerprint ?? ""; + const fingerprintShort = + fingerprint.length > 14 ? `${fingerprint.slice(0, 14)}…` : fingerprint; + return ( - + + + - - - - {signal.source_product} - - - {signal.source_type} - + + + + Fingerprint{" "} + + {fingerprintShort} + + + + {/* No "View issue" link in Code — error tracking lives in Cloud */} + + + + ); +} - - +function GenericSignalCard({ + signal, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + return ( + + + + + {new Date(signal.timestamp).toLocaleString()} + + + + + ); +} + +function CodePathsCollapsible({ paths }: { paths: string[] }) { + const [expanded, setExpanded] = useState(false); + + if (paths.length === 0) return null; - - - w:{signal.weight.toFixed(2)} - - - {new Date(signal.timestamp).toLocaleString()} - + return ( + + + {expanded && ( + + {paths.map((raw) => { + const trimmed = raw.trim(); + const parenIdx = trimmed.indexOf(" ("); + const filePath = + parenIdx >= 0 ? trimmed.slice(0, parenIdx) : trimmed; + const comment = parenIdx >= 0 ? trimmed.slice(parenIdx + 1) : null; + return ( + + + {filePath} + + {comment && ( + + {comment} + + )} + + ); + })} - + )} + + ); +} + +function DataQueriedCollapsible({ text }: { text: string }) { + const [expanded, setExpanded] = useState(false); + + if (!text) return null; + + return ( + + + {expanded && ( + + {text} + + )} ); } -export function SignalCard({ signal }: SignalCardProps) { - if (signal.source_product === "github") { - return ; +// ── Main export ────────────────────────────────────────────────────────────── + +export function SignalCard({ + signal, + finding, +}: { + signal: Signal; + finding?: SignalFindingContent; +}) { + const extra = parseExtra(signal.extra); + const verified = finding?.verified; + const codePaths = finding?.relevant_code_paths ?? []; + const dataQueried = finding?.data_queried ?? ""; + + 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/SignalReportActionabilityBadge.tsx b/apps/code/src/renderer/features/inbox/components/SignalReportActionabilityBadge.tsx new file mode 100644 index 000000000..3dbba2846 --- /dev/null +++ b/apps/code/src/renderer/features/inbox/components/SignalReportActionabilityBadge.tsx @@ -0,0 +1,57 @@ +import type { SignalReportActionability } from "@shared/types"; +import type { ReactNode } from "react"; + +const ACTIONABILITY_CHIP_STYLE: Record< + SignalReportActionability, + { color: string; backgroundColor: string; borderColor: string; label: string } +> = { + immediately_actionable: { + color: "var(--green-11)", + backgroundColor: "var(--green-3)", + borderColor: "var(--green-6)", + label: "Actionable", + }, + requires_human_input: { + color: "var(--amber-11)", + backgroundColor: "var(--amber-3)", + borderColor: "var(--amber-6)", + label: "Needs input", + }, + not_actionable: { + color: "var(--gray-11)", + backgroundColor: "var(--gray-3)", + borderColor: "var(--gray-6)", + label: "Not actionable", + }, +}; + +interface SignalReportActionabilityBadgeProps { + actionability: SignalReportActionability | null | undefined; +} + +export function SignalReportActionabilityBadge({ + actionability, +}: SignalReportActionabilityBadgeProps): ReactNode { + if (actionability == null) { + return null; + } + + const s = ACTIONABILITY_CHIP_STYLE[actionability]; + if (!s) { + return null; + } + + return ( + + {s.label} + + ); +} 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/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 dcb74a7aa..b4e532911 100644 --- a/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts +++ b/apps/code/src/renderer/features/inbox/hooks/useSignalSourceManager.ts @@ -1,33 +1,45 @@ import { useAuthenticatedClient } from "@features/auth/hooks/authClient"; import { useAuthStateValue } from "@features/auth/hooks/authQueries"; 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 } @@ -37,23 +49,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 = useAuthStateValue((state) => state.projectId); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); const client = useAuthenticatedClient(); 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); @@ -75,23 +125,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< @@ -111,29 +154,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( @@ -183,148 +245,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/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..4e719182c 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -164,6 +164,12 @@ export type SignalReportStatus = /** Actionability priority from the researched report (actionability judgment artefact). */ export type SignalReportPriority = "P0" | "P1" | "P2" | "P3" | "P4"; +/** Actionability choice from the researched report. */ +export type SignalReportActionability = + | "immediately_actionable" + | "requires_human_input" + | "not_actionable"; + /** * One or more `SignalReportStatus` values joined by commas, e.g. `potential` or `potential,candidate,ready`. * This looks horrendous but it's superb, trust me bro. @@ -187,8 +193,14 @@ export interface SignalReport { created_at: string; updated_at: string; artefact_count: number; - /** P0–P4 from actionability judgment when the report is researched */ + /** P0–P4 from priority judgment when the report is researched */ priority?: SignalReportPriority | null; + /** Actionability choice from the actionability judgment artefact. */ + actionability?: SignalReportActionability | null; + /** Whether the issue appears already fixed, from the actionability judgment artefact. */ + already_addressed?: boolean | null; + /** Whether the current user is a suggested reviewer for this report (server-annotated). */ + is_suggested_reviewer?: boolean; } export interface SignalReportArtefactContent { @@ -207,6 +219,77 @@ export interface SignalReportArtefact { created_at: string; } +/** Artefact with `type: "priority_judgment"` — priority assessment from the agentic report. */ +export interface PriorityJudgmentArtefact { + id: string; + type: "priority_judgment"; + content: PriorityJudgmentContent; + created_at: string; +} + +export interface PriorityJudgmentContent { + explanation: string; + priority: SignalReportPriority; +} + +/** Artefact with `type: "actionability_judgment"` — actionability assessment from the agentic report. */ +export interface ActionabilityJudgmentArtefact { + id: string; + type: "actionability_judgment"; + content: ActionabilityJudgmentContent; + created_at: string; +} + +export interface ActionabilityJudgmentContent { + explanation: string; + actionability: SignalReportActionability; + already_addressed: boolean; +} + +/** Artefact with `type: "signal_finding"` — per-signal research finding from the agentic report. */ +export interface SignalFindingArtefact { + id: string; + type: "signal_finding"; + content: SignalFindingContent; + created_at: string; +} + +export interface SignalFindingContent { + signal_id: string; + relevant_code_paths: string[]; + relevant_commit_hashes: Record; + data_queried: string; + verified: boolean; +} + +/** 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 +326,13 @@ export interface SignalReportSignalsResponse { } export interface SignalReportArtefactsResponse { - results: SignalReportArtefact[]; + results: ( + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | SuggestedReviewersArtefact + )[]; count: number; unavailableReason?: | "forbidden" @@ -253,6 +342,7 @@ export interface SignalReportArtefactsResponse { } export type SignalReportOrderingField = + | "priority" | "signal_count" | "total_weight" | "created_at" @@ -261,7 +351,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`,