From f4750fd99b0101f818175a667ceb9cd4f32042a4 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 10 Apr 2026 13:22:34 +0200 Subject: [PATCH 1/3] feat(inbox): support session problem signals Add dedicated rendering for session_problem signals in the inbox detail pane, including video playback and an "Events around the problem" table showing user-behavior events with human-readable names and millisecond timestamps. --- apps/code/src/renderer/api/posthogClient.ts | 39 +++ .../components/detail/ReportDetailPane.tsx | 126 +++----- .../inbox/components/detail/SignalCard.tsx | 269 +++++++++++++++++- 3 files changed, 342 insertions(+), 92 deletions(-) diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index d53ed8779..ca45ffae6 100644 --- a/apps/code/src/renderer/api/posthogClient.ts +++ b/apps/code/src/renderer/api/posthogClient.ts @@ -1990,4 +1990,43 @@ export class PostHogAPIClient { ); } } + + /** Find an exported asset by session recording ID. */ + async findExportBySessionRecordingId( + projectId: number, + sessionRecordingId: string, + ): Promise { + const urlPath = `/api/projects/${projectId}/exports/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + url.searchParams.set("session_recording_id", sessionRecordingId); + url.searchParams.set("export_format", "video/mp4"); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const data = (await response.json()) as { + results?: Array<{ id: number; has_content: boolean }>; + }; + const match = data.results?.find((e) => e.has_content); + return match?.id ?? null; + } + + /** Get the presigned content URL for an exported asset (e.g. rasterized recording). */ + async getExportContentUrl( + projectId: number, + exportId: number, + ): Promise { + const urlPath = `/api/projects/${projectId}/exports/${exportId}/content/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) return null; + const blob = await response.blob(); + return URL.createObjectURL(blob); + } } diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index 6831df750..a43f7b69f 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -14,7 +14,6 @@ import { ArrowSquareOutIcon, CaretDownIcon, CaretRightIcon, - ClockIcon, Cloud as CloudIcon, EyeIcon, GitPullRequestIcon, @@ -37,8 +36,6 @@ import type { PriorityJudgmentArtefact, SignalFindingArtefact, SignalReport, - SignalReportArtefact, - SignalReportArtefactsResponse, SuggestedReviewer, SuggestedReviewersArtefact, } from "@shared/types"; @@ -69,23 +66,6 @@ function isSuggestedReviewerRowMe( // ── Helpers ───────────────────────────────────────────────────────────────── -function getArtefactsUnavailableMessage( - reason: SignalReportArtefactsResponse["unavailableReason"], -): string { - switch (reason) { - case "forbidden": - return "Evidence could not be loaded with the current API permissions."; - case "not_found": - return "Evidence endpoint is unavailable for this signal in this environment."; - case "invalid_payload": - return "Evidence format was unexpected, so no artefacts could be shown."; - case "request_failed": - return "Evidence is temporarily unavailable. You can still create a task from this report."; - default: - return "Evidence is currently unavailable for this signal."; - } -} - function DetailRow({ label, value, @@ -161,10 +141,6 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { }); const allArtefacts = artefactsQuery.data?.results ?? []; - const videoSegments = allArtefacts.filter( - (a): a is SignalReportArtefact => a.type === "video_segment", - ); - const suggestedReviewers = useMemo(() => { const reviewerArtefact = allArtefacts.find( (a): a is SuggestedReviewersArtefact => a.type === "suggested_reviewers", @@ -203,17 +179,24 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { }, [allArtefacts]); const artefactsUnavailableReason = artefactsQuery.data?.unavailableReason; - const showArtefactsUnavailable = - !artefactsQuery.isLoading && - (!!artefactsQuery.error || !!artefactsUnavailableReason); - const artefactsUnavailableMessage = artefactsQuery.error - ? "Evidence could not be loaded right now. You can still create a task from this report." - : getArtefactsUnavailableMessage(artefactsUnavailableReason); + void artefactsUnavailableReason; // TODO: wire up unavailable UI const signalsQuery = useInboxReportSignals(report.id, { enabled: true, }); - const signals = signalsQuery.data?.signals ?? []; + const allSignals = signalsQuery.data?.signals ?? []; + const sessionProblemSignals = allSignals.filter( + (s) => + s.source_product === "session_replay" && + s.source_type === "session_problem", + ); + const signals = allSignals.filter( + (s) => + !( + s.source_product === "session_replay" && + s.source_type === "session_problem" + ), + ); // ── Task creation ─────────────────────────────────────────────────────── const { navigateToTask } = useNavigationStore(); @@ -524,67 +507,28 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { )} - {/* ── Evidence (session segments) ─────────────────────── */} - - - Evidence - - {artefactsQuery.isLoading && ( - - Loading evidence... - - )} - {showArtefactsUnavailable && ( - - {artefactsUnavailableMessage} + {/* ── Session problem evidence ─────────────────────────── */} + {sessionProblemSignals.length > 0 && ( + + + Evidence ({sessionProblemSignals.length}) - )} - {!artefactsQuery.isLoading && - !showArtefactsUnavailable && - videoSegments.length === 0 && ( - - No session segments available for this report. - - )} - - {videoSegments.map((artefact) => ( - - - {artefact.content.content} - - - - - - {artefact.content.start_time - ? new Date( - artefact.content.start_time, - ).toLocaleString() - : "Unknown time"} - - - {replayBaseUrl && artefact.content.session_id && ( - - View replay - - - )} - - - ))} - - + + {sessionProblemSignals.map((signal) => ( + + ))} + + + )} diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 66532960c..8fd2be074 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -1,5 +1,7 @@ +import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { MarkdownRenderer } from "@features/editor/components/MarkdownRenderer"; import { SOURCE_PRODUCT_META } from "@features/inbox/components/utils/source-product-icons"; +import { useAuthenticatedQuery } from "@hooks/useAuthenticatedQuery"; import { ArrowSquareOutIcon, CaretDownIcon, @@ -10,7 +12,7 @@ import { } from "@phosphor-icons/react"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import type { Signal, SignalFindingContent } from "@shared/types"; -import { useState } from "react"; +import { useRef, useState } from "react"; const COLLAPSE_THRESHOLD = 300; @@ -33,6 +35,12 @@ function signalCardSourceLine(signal: { ERROR_TRACKING_TYPE_LABELS[source_type] ?? source_type.replace(/_/g, " "); return `Error tracking · ${typeLabel}`; } + if ( + source_product === "session_replay" && + source_type === "session_problem" + ) { + return "Session replay · Session problem"; + } if ( source_product === "session_replay" && source_type === "session_segment_cluster" @@ -91,6 +99,27 @@ interface LlmEvalExtra { provider?: string; } +interface SessionProblemEventEntry { + event: string; + timestamp: string; + current_url?: string; + event_type?: string; + interaction_text?: string; +} + +interface SessionProblemExtra { + session_id?: string; + segment_title?: string; + start_time?: string; + end_time?: string; + problem_type?: string; + distinct_id?: string; + session_start_time?: string; + session_end_time?: string; + exported_asset_id?: number; + event_history?: SessionProblemEventEntry[]; +} + interface ErrorTrackingExtra { fingerprint?: string; } @@ -173,6 +202,14 @@ function isLlmEvalExtra( return "evaluation_id" in extra && "trace_id" in extra; } +function isSessionProblemExtra( + extra: Record, +): extra is Record & SessionProblemExtra { + return ( + "session_id" in extra && "problem_type" in extra && "segment_title" in extra + ); +} + function isErrorTrackingExtra( extra: Record, ): extra is Record & ErrorTrackingExtra { @@ -476,6 +513,221 @@ function LlmEvalSignalCard({ ); } +const PROBLEM_TYPE_LABELS: Record< + string, + { label: string; color: "red" | "orange" } +> = { + blocking_exception: { label: "Blocking exception", color: "red" }, + non_blocking_exception: { label: "Non-blocking exception", color: "orange" }, + abandonment: { label: "Abandonment", color: "red" }, + confusion: { label: "Confusion", color: "orange" }, + failure: { label: "Failure", color: "red" }, +}; + +// Human-readable labels for common PostHog dollar-prefixed event names +const EVENT_DISPLAY_NAMES: Record = { + $pageview: "Pageview", + $autocapture: "Autocapture", + $exception: "Exception", + $rageclick: "Rageclick", + $dead_click: "Dead click", + $screen: "Screen", + $csp_violation: "CSP violation", + $pageleave: "Pageleave", +}; + +function eventDisplayName(raw: string): string { + return EVENT_DISPLAY_NAMES[raw] ?? raw; +} + +function EventHistoryTable({ events }: { events: SessionProblemEventEntry[] }) { + return ( + + + Events around the problem + + + + + {events.map((entry) => ( + + + + {entry.current_url ? ( + + ) : null} + + ))} + +
+ {entry.timestamp} + + + {eventDisplayName(entry.event)} + + {entry.event_type ? ( + + [{entry.event_type}] + + ) : null} + {entry.interaction_text ? ( + + "{entry.interaction_text}" + + ) : null} + + {entry.current_url} +
+
+
+ ); +} + +function SessionProblemSignalCard({ + signal, + extra, + verified, + codePaths, + dataQueried, +}: { + signal: Signal; + extra: SessionProblemExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; +}) { + const problemInfo = extra.problem_type + ? (PROBLEM_TYPE_LABELS[extra.problem_type] ?? { + label: extra.problem_type.replace(/_/g, " "), + color: "orange" as const, + }) + : null; + const hasEventHistory = extra.event_history && extra.event_history.length > 0; + + return ( + + + + + {extra.session_id && ( + + )} + {hasEventHistory && ( + + + + )} + + + {problemInfo && ( + + {problemInfo.label} + + )} + {extra.distinct_id && ( + + {extra.distinct_id.slice(0, 10)}… + + )} + {extra.start_time && extra.end_time && ( + <> + · + + {extra.start_time} – {extra.end_time} + + + )} + + + + + ); +} + +function SessionRecordingVideo({ + exportedAssetId, + sessionId, +}: { + exportedAssetId?: number; + sessionId: string; +}) { + const projectId = useAuthStateValue((state) => state.projectId); + const videoRef = useRef(null); + const videoQuery = useAuthenticatedQuery( + ["export-video", projectId, exportedAssetId, sessionId], + async (client) => { + if (!projectId) return null; + let assetId: number | null = exportedAssetId ?? null; + // If no asset ID in the signal, look up the export by session_id + if (assetId == null) { + assetId = await client.findExportBySessionRecordingId( + projectId, + sessionId, + ); + if (assetId == null) return null; + } + return client.getExportContentUrl(projectId, assetId); + }, + { enabled: !!projectId, staleTime: Infinity }, + ); + + if (videoQuery.isError || videoQuery.data === null) return null; + if (videoQuery.isLoading || videoQuery.data === undefined) { + return ( + + Loading recording… + + ); + } + + return ( + + + ); +} + function ErrorTrackingSignalCard({ signal, verified, @@ -609,6 +861,21 @@ export function SignalCard({ const codePaths = finding?.relevant_code_paths ?? []; const dataQueried = finding?.data_queried ?? ""; + if ( + signal.source_product === "session_replay" && + signal.source_type === "session_problem" && + isSessionProblemExtra(extra) + ) { + return ( + + ); + } if ( signal.source_product === "error_tracking" && isErrorTrackingExtra(extra) From 8dc4e6ce7738a87a6d2b8dbb896cfe13a8edb3d4 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Fri, 17 Apr 2026 17:17:25 +0200 Subject: [PATCH 2/3] Drop event_history --- .../inbox/components/detail/SignalCard.tsx | 89 ------------------- 1 file changed, 89 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index 8fd2be074..c9c09b73e 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -99,14 +99,6 @@ interface LlmEvalExtra { provider?: string; } -interface SessionProblemEventEntry { - event: string; - timestamp: string; - current_url?: string; - event_type?: string; - interaction_text?: string; -} - interface SessionProblemExtra { session_id?: string; segment_title?: string; @@ -117,7 +109,6 @@ interface SessionProblemExtra { session_start_time?: string; session_end_time?: string; exported_asset_id?: number; - event_history?: SessionProblemEventEntry[]; } interface ErrorTrackingExtra { @@ -524,80 +515,6 @@ const PROBLEM_TYPE_LABELS: Record< failure: { label: "Failure", color: "red" }, }; -// Human-readable labels for common PostHog dollar-prefixed event names -const EVENT_DISPLAY_NAMES: Record = { - $pageview: "Pageview", - $autocapture: "Autocapture", - $exception: "Exception", - $rageclick: "Rageclick", - $dead_click: "Dead click", - $screen: "Screen", - $csp_violation: "CSP violation", - $pageleave: "Pageleave", -}; - -function eventDisplayName(raw: string): string { - return EVENT_DISPLAY_NAMES[raw] ?? raw; -} - -function EventHistoryTable({ events }: { events: SessionProblemEventEntry[] }) { - return ( - - - Events around the problem - - - - - {events.map((entry) => ( - - - - {entry.current_url ? ( - - ) : null} - - ))} - -
- {entry.timestamp} - - - {eventDisplayName(entry.event)} - - {entry.event_type ? ( - - [{entry.event_type}] - - ) : null} - {entry.interaction_text ? ( - - "{entry.interaction_text}" - - ) : null} - - {entry.current_url} -
-
-
- ); -} - function SessionProblemSignalCard({ signal, extra, @@ -617,7 +534,6 @@ function SessionProblemSignalCard({ color: "orange" as const, }) : null; - const hasEventHistory = extra.event_history && extra.event_history.length > 0; return ( @@ -630,11 +546,6 @@ function SessionProblemSignalCard({ sessionId={extra.session_id} /> )} - {hasEventHistory && ( - - - - )} Date: Fri, 17 Apr 2026 18:07:21 +0200 Subject: [PATCH 3/3] Update SignalCard.tsx --- .../components/detail/ReportDetailPane.tsx | 9 ----- .../inbox/components/detail/SignalCard.tsx | 36 +++++++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx index a43f7b69f..248500546 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -1,5 +1,4 @@ import { Badge } from "@components/ui/Badge"; -import { useAuthStateValue } from "@features/auth/hooks/authQueries"; import { GitHubRepoPicker } from "@features/folder-picker/components/GitHubRepoPicker"; import { useInboxReportArtefacts, @@ -39,7 +38,6 @@ import type { SuggestedReviewer, SuggestedReviewersArtefact, } from "@shared/types"; -import { getCloudUrlFromRegion } from "@shared/utils/urls"; import { useNavigationStore } from "@stores/navigationStore"; import { type ReactNode, @@ -126,14 +124,7 @@ interface ReportDetailPaneProps { } export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { - // ── Auth / URLs ───────────────────────────────────────────────────────── - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - const projectId = useAuthStateValue((state) => state.projectId); const { data: me } = useMeQuery(); - const replayBaseUrl = - cloudRegion && projectId - ? `${getCloudUrlFromRegion(cloudRegion)}/project/${projectId}/replay` - : null; // ── Report data ───────────────────────────────────────────────────────── const artefactsQuery = useInboxReportArtefacts(report.id, { diff --git a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx index c9c09b73e..03fee248a 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -108,6 +108,8 @@ interface SessionProblemExtra { distinct_id?: string; session_start_time?: string; session_end_time?: string; + session_duration?: number; + session_active_seconds?: number; exported_asset_id?: number; } @@ -515,6 +517,16 @@ const PROBLEM_TYPE_LABELS: Record< failure: { label: "Failure", color: "red" }, }; +function formatSessionDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + const hrs = Math.floor(mins / 60); + const remainMins = mins % 60; + return remainMins > 0 ? `${hrs}h ${remainMins}m` : `${hrs}h`; +} + function SessionProblemSignalCard({ signal, extra, @@ -538,6 +550,11 @@ function SessionProblemSignalCard({ return ( + {extra.segment_title && ( + + {extra.segment_title} + + )} {extra.session_id && ( @@ -578,6 +595,25 @@ function SessionProblemSignalCard({ )} + {extra.session_duration != null && ( + <> + · + {formatSessionDuration(extra.session_duration)} session + + )} + {extra.session_active_seconds != null && + extra.session_duration != null && + extra.session_duration > 0 && ( + <> + · + + {Math.round( + (extra.session_active_seconds / extra.session_duration) * 100, + )} + % active + + + )}