diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index ca863b679..e78e2339d 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/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..60eeba549 100644 --- a/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx +++ b/apps/code/src/renderer/features/inbox/components/InboxSignalsTab.tsx @@ -42,6 +42,7 @@ import { } from "@radix-ui/themes"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { + SignalReportArtefact, SignalReportArtefactsResponse, SignalReportsQueryParams, } from "@shared/types"; @@ -201,7 +202,9 @@ export function InboxSignalsTab() { const artefactsQuery = useInboxReportArtefacts(selectedReport?.id ?? "", { enabled: !!selectedReport, }); - const visibleArtefacts = artefactsQuery.data?.results ?? []; + const visibleArtefacts = (artefactsQuery.data?.results ?? []).filter( + (a): a is SignalReportArtefact => a.type !== "suggested_reviewers", + ); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; const showArtefactsUnavailable = !artefactsQuery.isLoading && 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..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`,