From 523addd75e342e6eb2c1485760b536a0bcf77245 Mon Sep 17 00:00:00 2001 From: Alex Lebedev Date: Wed, 8 Apr 2026 12:35:50 +0200 Subject: [PATCH] feat(inbox): show signals report data in detail pane - Add Priority and Actionability detail rows with collapsible "Why?" explanations from priority_judgment and actionability_judgment artefacts - Add 'already addressed' warning banner driven by report or judgment - Pass per-signal SignalFinding (verified flag, code paths, data queried) to SignalCard so the detail pane can surface research evidence - Add VerificationBadge to SignalCardHeader and CodePaths/DataQueried collapsibles inside each source-specific signal card variant - Add SignalReportActionabilityBadge alongside the existing priority/ status chips on the inbox list card - Restructure the evidence section in ReportDetailPane to surface loading and unavailable states (forbidden, not_found, invalid_payload, request_failed) so the user gets feedback when artefacts can't load - Add typed normalizers for priority_judgment, actionability_judgment and signal_finding artefacts in posthogClient - Drop the legacy JudgmentBadges component, which used an outdated Record shape that no longer matches the typed artefact schema in shared/types Squashed-from: signals/new-report-data-in-inbox-ui-backup Originally PR PostHog/code#1428 --- apps/code/src/renderer/api/posthogClient.ts | 126 +++++- .../components/detail/ReportDetailPane.tsx | 410 ++++++++++-------- .../inbox/components/detail/SignalCard.tsx | 219 +++++++++- .../inbox/components/list/ReportCard.tsx | 4 + .../utils/SignalReportActionabilityBadge.tsx | 57 +++ apps/code/src/shared/types.ts | 63 ++- 6 files changed, 676 insertions(+), 203 deletions(-) create mode 100644 apps/code/src/renderer/features/inbox/components/utils/SignalReportActionabilityBadge.tsx diff --git a/apps/code/src/renderer/api/posthogClient.ts b/apps/code/src/renderer/api/posthogClient.ts index 49ff66b9f..a6a2d33fc 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, @@ -71,19 +74,138 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } -type AnyArtefact = SignalReportArtefact | SuggestedReviewersArtefact; +type AnyArtefact = + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | SuggestedReviewersArtefact; + +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): AnyArtefact | null { if (!isObjectRecord(value)) { return null; } + const dispatchType = optionalString(value.type); + if (dispatchType === "signal_finding") { + return normalizeSignalFindingArtefact(value); + } + if (dispatchType === "actionability_judgment") { + return normalizeActionabilityJudgmentArtefact(value); + } + if (dispatchType === "priority_judgment") { + return normalizePriorityJudgmentArtefact(value); + } + const id = optionalString(value.id); if (!id) { return null; } - const type = optionalString(value.type) ?? "unknown"; + const type = dispatchType ?? "unknown"; const created_at = optionalString(value.created_at) ?? new Date(0).toISOString(); 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 ca5cc306b..6da0d7cfb 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/ReportDetailPane.tsx @@ -11,8 +11,8 @@ import { useFeatureFlag } from "@hooks/useFeatureFlag"; import { useRepositoryIntegration } from "@hooks/useIntegrations"; import { ArrowSquareOutIcon, + CaretDownIcon, CaretRightIcon, - CheckIcon, ClockIcon, Cloud as CloudIcon, CommandIcon, @@ -23,7 +23,6 @@ import { } from "@phosphor-icons/react"; import { AlertDialog, - Badge, Box, Button, Flex, @@ -34,140 +33,97 @@ import { } from "@radix-ui/themes"; import { getCloudUrlFromRegion } from "@shared/constants/oauth"; import type { + ActionabilityJudgmentArtefact, + ActionabilityJudgmentContent, + PriorityJudgmentArtefact, + SignalFindingArtefact, SignalReport, SignalReportArtefact, + SignalReportArtefactsResponse, SuggestedReviewersArtefact, } from "@shared/types"; import { useNavigationStore } from "@stores/navigationStore"; -import { useCallback, useEffect, useMemo, useState } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useMemo, + useState, +} from "react"; import { toast } from "sonner"; +import { SignalReportActionabilityBadge } from "../utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "../utils/SignalReportPriorityBadge"; import { SignalReportStatusBadge } from "../utils/SignalReportStatusBadge"; import { SignalReportSummaryMarkdown } from "../utils/SignalReportSummaryMarkdown"; import { ReportTaskLogs } from "./ReportTaskLogs"; import { SignalCard } from "./SignalCard"; -// ── JudgmentBadges (only used in detail pane) ─────────────────────────────── +// ── 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 JudgmentBadges({ - safetyContent, - actionabilityContent, +function DetailRow({ + label, + value, + explanation, }: { - safetyContent: Record | null; - actionabilityContent: Record | null; + label: string; + value: ReactNode; + explanation?: string | null; }) { const [expanded, setExpanded] = useState(false); - - const isSafe = - safetyContent?.safe === true || safetyContent?.judgment === "safe"; - const actionabilityJudgment = - (actionabilityContent?.judgment as string) ?? ""; - - const actionabilityLabel = - actionabilityJudgment === "immediately_actionable" - ? "Immediately actionable" - : actionabilityJudgment === "requires_human_input" - ? "Requires human input" - : "Not actionable"; - - const actionabilityColor = - actionabilityJudgment === "immediately_actionable" - ? "green" - : actionabilityJudgment === "requires_human_input" - ? "amber" - : "gray"; + const hasExplanation = !!explanation; return ( - - - {expanded && ( - setExpanded((v) => !v)} + className="flex items-center gap-0.5 rounded px-1 py-0.5 text-[13px] text-gray-9 hover:bg-gray-3 hover:text-gray-11" + > + {expanded ? ( + + ) : ( + + )} + Why? + + )} + + {expanded && explanation && ( + - {safetyContent?.explanation ? ( - - - Safety - - - {String(safetyContent.explanation)} - - - ) : null} - {actionabilityContent?.explanation ? ( - - - Actionability - - - {String(actionabilityContent.explanation)} - - - ) : null} - + {explanation} + )} ); @@ -206,22 +162,44 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { return reviewerArtefact?.content ?? []; }, [allArtefacts]); - const judgments = useMemo(() => { - const safety = allArtefacts.find((a) => a.type === "safety_judgment"); - const actionability = allArtefacts.find( - (a) => a.type === "actionability_judgment", - ); - const safetyContent = - safety && !Array.isArray(safety.content) - ? (safety.content as unknown as Record) - : null; - const actionabilityContent = - actionability && !Array.isArray(actionability.content) - ? (actionability.content as unknown as Record) - : null; - return { safetyContent, actionabilityContent }; + 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 && + (!!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); + const signalsQuery = useInboxReportSignals(report.id, { enabled: true, }); @@ -398,7 +376,63 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { variant="detail" /> )} - + + {/* ── Priority / Actionability ──────────────────────── */} + {(report.priority || report.actionability) && ( + + {report.priority && ( + + } + explanation={priorityExplanation} + /> + )} + {report.actionability && ( + + } + explanation={actionabilityJudgment?.explanation} + /> + )} + + )} + + {/* ── Already-addressed warning ─────────────────────── */} + {(report.already_addressed ?? + actionabilityJudgment?.already_addressed) && ( + + + + This issue may already be addressed in recent code changes. + + + )} {/* ── Suggested reviewers ─────────────────────────────── */} {suggestedReviewers.length > 0 && ( @@ -475,7 +509,11 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { {signals.map((signal) => ( - + ))} @@ -486,65 +524,67 @@ export function ReportDetailPane({ report, onClose }: ReportDetailPaneProps) { )} - {/* ── LLM judgments ──────────────────────────────────── */} - {(judgments.safetyContent || judgments.actionabilityContent) && ( - - )} - - {/* ── Session segments (video artefacts) ──────────────── */} - {videoSegments.length > 0 && ( - - - Session segments + {/* ── Evidence (session segments) ─────────────────────── */} + + + Evidence + + {artefactsQuery.isLoading && ( + + Loading evidence... - - {videoSegments.map((artefact) => ( - + {artefactsUnavailableMessage} + + )} + {!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 - - - )} + {artefact.content.content} + + + + + + {artefact.content.start_time + ? new Date( + artefact.content.start_time, + ).toLocaleString() + : "Unknown time"} + - - ))} - - - )} + {replayBaseUrl && artefact.content.session_id && ( + + View replay + + + )} + + + ))} + + 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 74551e473..ad96ac6ed 100644 --- a/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/detail/SignalCard.tsx @@ -4,11 +4,13 @@ import { ArrowSquareOutIcon, CaretDownIcon, CaretRightIcon, + 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; @@ -180,7 +182,34 @@ function isErrorTrackingExtra( // ── Shared components ──────────────────────────────────────────────────────── -function SignalCardHeader({ signal }: { signal: Signal }) { +function VerificationBadge({ verified }: { verified: boolean }) { + return ( + + {verified ? ( + + ) : ( + + )} + {verified ? "Verified" : "Unverified"} + + ); +} + +function SignalCardHeader({ + signal, + verified, +}: { + signal: Signal; + verified?: boolean; +}) { const meta = SOURCE_PRODUCT_META[signal.source_product]; return ( @@ -207,6 +236,7 @@ function SignalCardHeader({ signal }: { signal: Signal }) { {signalCardSourceLine(signal)} + {verified !== undefined && } - + )} + + ); } @@ -328,13 +366,19 @@ function GitHubIssueSignalCard({ function ZendeskTicketSignalCard({ signal, extra, + verified, + codePaths, + dataQueried, }: { signal: Signal; extra: ZendeskTicketExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; }) { return ( - + )} + + ); } @@ -385,13 +431,19 @@ function ZendeskTicketSignalCard({ function LlmEvalSignalCard({ signal, extra, + verified, + codePaths, + dataQueried, }: { signal: Signal; extra: LlmEvalExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; }) { return ( - + {extra.trace_id.slice(0, 12)}... )} + + ); } @@ -421,15 +475,21 @@ function LlmEvalSignalCard({ function ErrorTrackingSignalCard({ signal, extra, + verified, + codePaths, + dataQueried, }: { signal: Signal; extra: ErrorTrackingExtra; + verified?: boolean; + codePaths?: string[]; + dataQueried?: string; }) { const fingerprint = extra.fingerprint ?? ""; return ( - + {/* No "View issue" link in Code — error tracking lives in Cloud */} + + ); } -function GenericSignalCard({ signal }: { signal: Signal }) { +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; + + 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} + + )} ); } // ── Main export ────────────────────────────────────────────────────────────── -export function SignalCard({ signal }: { signal: Signal }) { +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 ; + return ( + + ); } if (signal.source_product === "github" && isGithubIssueExtra(extra)) { - return ; + return ( + + ); } if (signal.source_product === "zendesk" && isZendeskTicketExtra(extra)) { - return ; + return ( + + ); } if (signal.source_product === "llm_analytics" && isLlmEvalExtra(extra)) { - return ; + return ( + + ); } - return ; + return ( + + ); } diff --git a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx b/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx index 57e5b5157..1850267fc 100644 --- a/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx +++ b/apps/code/src/renderer/features/inbox/components/list/ReportCard.tsx @@ -1,3 +1,4 @@ +import { SignalReportActionabilityBadge } from "@features/inbox/components/utils/SignalReportActionabilityBadge"; import { SignalReportPriorityBadge } from "@features/inbox/components/utils/SignalReportPriorityBadge"; import { SignalReportStatusBadge } from "@features/inbox/components/utils/SignalReportStatusBadge"; import { SignalReportSummaryMarkdown } from "@features/inbox/components/utils/SignalReportSummaryMarkdown"; @@ -128,6 +129,9 @@ export function ReportCard({ + {report.is_suggested_reviewer && ( = { + 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/shared/types.ts b/apps/code/src/shared/types.ts index 6ae90abb6..85014bcfc 100644 --- a/apps/code/src/shared/types.ts +++ b/apps/code/src/shared/types.ts @@ -170,6 +170,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. @@ -193,8 +199,12 @@ 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; /** Distinct source products contributing signals to this report (e.g. "session_replay", "error_tracking"). */ @@ -217,6 +227,49 @@ 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; @@ -281,7 +334,13 @@ export interface SignalReportSignalsResponse { } export interface SignalReportArtefactsResponse { - results: (SignalReportArtefact | SuggestedReviewersArtefact)[]; + results: ( + | SignalReportArtefact + | PriorityJudgmentArtefact + | ActionabilityJudgmentArtefact + | SignalFindingArtefact + | SuggestedReviewersArtefact + )[]; count: number; unavailableReason?: | "forbidden"