From ba47ef6360b13809dc4ce95e214571ad8b08c664 Mon Sep 17 00:00:00 2001 From: Alex LaGuardia Date: Sat, 1 Aug 2026 16:10:36 +0000 Subject: [PATCH] feat(desktop): group report findings by type in the inbox detail --- .../inbox/components/SignalsList.stories.tsx | 109 +++++++++++++++ .../features/inbox/components/SignalsList.tsx | 72 +++++++++- .../inbox/components/detail/SignalCard.tsx | 95 +------------ .../components/detail/signalCardSourceLine.ts | 95 +++++++++++++ .../inbox/components/signalGrouping.test.ts | 128 ++++++++++++++++++ .../inbox/components/signalGrouping.ts | 59 ++++++++ 6 files changed, 461 insertions(+), 97 deletions(-) create mode 100644 products/desktop/packages/ui/src/features/inbox/components/SignalsList.stories.tsx create mode 100644 products/desktop/packages/ui/src/features/inbox/components/detail/signalCardSourceLine.ts create mode 100644 products/desktop/packages/ui/src/features/inbox/components/signalGrouping.test.ts create mode 100644 products/desktop/packages/ui/src/features/inbox/components/signalGrouping.ts diff --git a/products/desktop/packages/ui/src/features/inbox/components/SignalsList.stories.tsx b/products/desktop/packages/ui/src/features/inbox/components/SignalsList.stories.tsx new file mode 100644 index 000000000000..b7082cb8d01f --- /dev/null +++ b/products/desktop/packages/ui/src/features/inbox/components/SignalsList.stories.tsx @@ -0,0 +1,109 @@ +import type { Signal } from "@posthog/shared/types"; +import type { Meta, StoryObj } from "@storybook/react"; +import { SignalsList } from "./SignalsList"; + +const meta: Meta = { + title: "Inbox/SignalsList", + component: SignalsList, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +let seq = 0; +function signal(overrides: Partial): Signal { + seq += 1; + return { + signal_id: `sig-${seq}`, + content: "Checkout requests started returning 502s after the deploy.", + source_product: "error_tracking", + source_type: "issue_created", + source_id: `src-${seq}`, + weight: 1, + timestamp: "2026-07-30T14:00:00Z", + extra: { fingerprint: `fp-${seq}` }, + ...overrides, + }; +} + +const githubIssue = (n: number): Signal => + signal({ + source_product: "github", + source_type: "issue", + content: `Users report the export button does nothing on large projects (#${n}).`, + extra: { + html_url: `https://github.com/acme/app/issues/${n}`, + number: n, + labels: [{ name: "bug", color: "d73a4a" }], + created_at: "2026-07-29T09:00:00Z", + }, + }); + +/** Many findings, few types: the grouped view with counts per type. */ +export const Grouped: Story = { + args: { + signals: [ + signal({}), + signal({ content: "TypeError: cannot read properties of undefined." }), + signal({ + source_type: "issue_spiking", + content: "502 volume spiked 8x.", + }), + signal({}), + signal({ + source_type: "issue_spiking", + content: "Timeout volume spiked.", + }), + githubIssue(101), + githubIssue(102), + signal({ + source_product: "llm_analytics", + source_type: "evaluation", + content: "Evaluation score dropped below threshold on the support bot.", + extra: { evaluation_id: "eval-1", trace_id: "trace-abc123456789" }, + }), + signal({}), + ], + }, +}; + +/** Below the grouping threshold: the flat list. */ +export const Flat: Story = { + args: { + signals: [signal({}), githubIssue(103)], + }, +}; + +/** Enough findings but every type distinct: grouping would not compress, stays flat. */ +export const FlatAllDistinctTypes: Story = { + args: { + signals: [ + signal({}), + signal({ source_type: "issue_spiking" }), + githubIssue(104), + signal({ + source_product: "zendesk", + source_type: "ticket", + content: "Customer cannot log in since this morning.", + extra: { + url: "https://acme.zendesk.com/api/v2/tickets/42.json", + priority: "high", + status: "open", + }, + }), + signal({ + source_product: "llm_analytics", + source_type: "evaluation", + content: "Hallucination rate above target.", + extra: { evaluation_id: "eval-2", trace_id: "trace-def123456789" }, + }), + ], + }, +}; diff --git a/products/desktop/packages/ui/src/features/inbox/components/SignalsList.tsx b/products/desktop/packages/ui/src/features/inbox/components/SignalsList.tsx index e09a6a2a12ae..c5e2ed31b958 100644 --- a/products/desktop/packages/ui/src/features/inbox/components/SignalsList.tsx +++ b/products/desktop/packages/ui/src/features/inbox/components/SignalsList.tsx @@ -1,21 +1,87 @@ +import { CaretDownIcon, CaretRightIcon } from "@phosphor-icons/react"; import type { Signal } from "@posthog/shared/types"; import { SignalCard } from "@posthog/ui/features/inbox/components/detail/SignalCard"; -import { Box, Flex } from "@radix-ui/themes"; +import { + groupSignalsByType, + type SignalGroup, + shouldGroupSignals, +} from "@posthog/ui/features/inbox/components/signalGrouping"; +import { getSourceProductMeta } from "@posthog/ui/features/inbox/components/utils/source-product-icons"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useMemo, useState } from "react"; interface SignalsListProps { signals: Signal[]; } export function SignalsList({ signals }: SignalsListProps) { + const groups = useMemo(() => groupSignalsByType(signals), [signals]); + + if (!shouldGroupSignals(groups, signals.length)) { + return ( + + {signals.map((signal) => ( + + ))} + + ); + } + return ( - {signals.map((signal) => ( - + {groups.map((group) => ( + ))} ); } +function SignalGroupSection({ group }: { group: SignalGroup }) { + const [expanded, setExpanded] = useState(false); + const meta = getSourceProductMeta(group.sourceProduct); + + return ( + + + {expanded && ( + + {group.signals.map((signal) => ( + + ))} + + )} + + ); +} + /** * Placeholder list rendered while the signals query is in flight. We already * know the count from `report.signal_count`, so the side column reserves the diff --git a/products/desktop/packages/ui/src/features/inbox/components/detail/SignalCard.tsx b/products/desktop/packages/ui/src/features/inbox/components/detail/SignalCard.tsx index 755923ce84b1..56b358f4628e 100644 --- a/products/desktop/packages/ui/src/features/inbox/components/detail/SignalCard.tsx +++ b/products/desktop/packages/ui/src/features/inbox/components/detail/SignalCard.tsx @@ -14,6 +14,7 @@ import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { errorTrackingIssueUrl } from "@posthog/ui/utils/posthogLinks"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import { useCallback, useMemo, useRef, useState } from "react"; +import { parseExtra, signalCardSourceLine } from "./signalCardSourceLine"; import { type SignalInteractionAction, SignalInteractionContext, @@ -22,89 +23,6 @@ import { const COLLAPSE_THRESHOLD = 300; -// ── 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", -}; - -// Turn a scout's skill_name (e.g. "signals-scout-error-tracking") into a -// human-friendly label (e.g. "Error tracking"). -function prettifyScoutName(skillName: string): string { - const cleaned = skillName - .replace(/^signals-scout-/, "") - .replace(/[-_]/g, " ") - .trim(); - if (!cleaned) return ""; - return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); -} - -function signalCardSourceLine(signal: { - source_product: string; - source_type: string; - extra?: Record; -}): 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_problem" - ) { - return "Session replay · Session problem"; - } - 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 "AI observability · 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"; - } - if (source_product === "pganalyze" && source_type === "issue") { - return "pganalyze · Issue"; - } - if (source_product === "health_checks" && source_type === "health_issue") { - return "Health checks · Issue"; - } - if ( - source_product === "signals_scout" && - source_type === "cross_source_issue" - ) { - const skillName = - typeof signal.extra?.skill_name === "string" - ? prettifyScoutName(signal.extra.skill_name) - : ""; - return skillName ? `Scout · ${skillName}` : "Scout · Cross-source issue"; - } - - const productLabel = source_product.replace(/_/g, " "); - const typeLabel = source_type.replace(/_/g, " "); - return `${productLabel} · ${typeLabel}`; -} - // ── Shared utilities ───────────────────────────────────────────────────────── interface GitHubLabelObject { @@ -207,17 +125,6 @@ function zendeskWebUrl(apiUrl: string): string { return `${origin}/agent/tickets/${id}`; } -function parseExtra(raw: Record): Record { - if (typeof raw === "string") { - try { - return JSON.parse(raw) as Record; - } catch { - return {}; - } - } - return raw; -} - // ── Type guards ────────────────────────────────────────────────────────────── function isGithubIssueExtra( diff --git a/products/desktop/packages/ui/src/features/inbox/components/detail/signalCardSourceLine.ts b/products/desktop/packages/ui/src/features/inbox/components/detail/signalCardSourceLine.ts new file mode 100644 index 000000000000..2e863b9bcef2 --- /dev/null +++ b/products/desktop/packages/ui/src/features/inbox/components/detail/signalCardSourceLine.ts @@ -0,0 +1,95 @@ +// ── 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", +}; + +// Turn a scout's skill_name (e.g. "signals-scout-error-tracking") into a +// human-friendly label (e.g. "Error tracking"). +function prettifyScoutName(skillName: string): string { + const cleaned = skillName + .replace(/^signals-scout-/, "") + .replace(/[-_]/g, " ") + .trim(); + if (!cleaned) return ""; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +export function signalCardSourceLine(signal: { + source_product: string; + source_type: string; + extra?: Record; +}): 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_problem" + ) { + return "Session replay · Session problem"; + } + 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 "AI observability · 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"; + } + if (source_product === "pganalyze" && source_type === "issue") { + return "pganalyze · Issue"; + } + if (source_product === "health_checks" && source_type === "health_issue") { + return "Health checks · Issue"; + } + if ( + source_product === "signals_scout" && + source_type === "cross_source_issue" + ) { + const skillName = + typeof signal.extra?.skill_name === "string" + ? prettifyScoutName(signal.extra.skill_name) + : ""; + return skillName ? `Scout · ${skillName}` : "Scout · Cross-source issue"; + } + + const productLabel = source_product.replace(/_/g, " "); + const typeLabel = source_type.replace(/_/g, " "); + return `${productLabel} · ${typeLabel}`; +} + +export function parseExtra( + raw: Record, +): Record { + if (typeof raw === "string") { + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } + } + return raw; +} diff --git a/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.test.ts b/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.test.ts new file mode 100644 index 000000000000..bbf700ff81a0 --- /dev/null +++ b/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.test.ts @@ -0,0 +1,128 @@ +import type { Signal } from "@posthog/shared/types"; +import { describe, expect, it } from "vitest"; +import { groupSignalsByType, shouldGroupSignals } from "./signalGrouping"; + +function makeSignal( + overrides: Partial & { signal_id: string }, +): Signal { + return { + content: "", + source_product: "github", + source_type: "issue", + source_id: "src-1", + weight: 1, + timestamp: "2026-08-01T00:00:00Z", + extra: {}, + ...overrides, + }; +} + +describe("signalGrouping", () => { + it("buckets by source line and keeps incoming order within each group", () => { + const signals = [ + makeSignal({ signal_id: "gh-1" }), + makeSignal({ + signal_id: "err-1", + source_product: "error_tracking", + source_type: "issue_created", + }), + makeSignal({ signal_id: "gh-2" }), + makeSignal({ + signal_id: "err-2", + source_product: "error_tracking", + source_type: "issue_created", + }), + makeSignal({ signal_id: "gh-3" }), + ]; + + const groups = groupSignalsByType(signals); + + expect(groups.map((g) => g.label)).toEqual([ + "GitHub · Issue", + "Error tracking · New issue", + ]); + expect(groups[0].signals.map((s) => s.signal_id)).toEqual([ + "gh-1", + "gh-2", + "gh-3", + ]); + expect(groups[1].signals.map((s) => s.signal_id)).toEqual([ + "err-1", + "err-2", + ]); + }); + + it("splits scout findings by skill instead of one scout bucket", () => { + const scout = (signal_id: string, skill_name: string): Signal => + makeSignal({ + signal_id, + source_product: "signals_scout", + source_type: "cross_source_issue", + extra: { skill_name }, + }); + const groups = groupSignalsByType([ + scout("s-1", "signals-scout-error-tracking"), + scout("s-2", "signals-scout-session-replay"), + scout("s-3", "signals-scout-error-tracking"), + ]); + + expect(groups.map((g) => [g.label, g.signals.length])).toEqual([ + ["Scout · Error tracking", 2], + ["Scout · Session replay", 1], + ]); + }); + + it("orders groups largest first, first-appearance on ties", () => { + const groups = groupSignalsByType([ + makeSignal({ + signal_id: "z-1", + source_product: "zendesk", + source_type: "ticket", + }), + makeSignal({ signal_id: "gh-1" }), + makeSignal({ + signal_id: "err-1", + source_product: "error_tracking", + source_type: "issue_spiking", + }), + makeSignal({ + signal_id: "err-2", + source_product: "error_tracking", + source_type: "issue_spiking", + }), + makeSignal({ signal_id: "gh-2" }), + ]); + + expect(groups.map((g) => g.label)).toEqual([ + "GitHub · Issue", + "Error tracking · Volume spike", + "Zendesk · Ticket", + ]); + expect(groups.map((g) => g.signals.length)).toEqual([2, 2, 1]); + }); + + it.each([ + { name: "below minimum count", total: 4, distinct: 2, expected: false }, + { name: "no type repeats", total: 5, distinct: 5, expected: false }, + { + name: "enough findings with repeats", + total: 5, + distinct: 2, + expected: true, + }, + { name: "single repeated type", total: 6, distinct: 1, expected: true }, + ])( + "grouping engages only when it compresses: $name", + ({ total, distinct, expected }) => { + const signals = Array.from({ length: total }, (_, i) => + makeSignal({ + signal_id: `s-${i}`, + source_type: `type_${i % distinct}`, + }), + ); + const groups = groupSignalsByType(signals); + expect(groups).toHaveLength(distinct); + expect(shouldGroupSignals(groups, signals.length)).toBe(expected); + }, + ); +}); diff --git a/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.ts b/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.ts new file mode 100644 index 000000000000..24e228af9877 --- /dev/null +++ b/products/desktop/packages/ui/src/features/inbox/components/signalGrouping.ts @@ -0,0 +1,59 @@ +import type { Signal } from "@posthog/shared/types"; +import { + parseExtra, + signalCardSourceLine, +} from "@posthog/ui/features/inbox/components/detail/signalCardSourceLine"; + +export interface SignalGroup { + /** Grouping key: the rendered source line, so it can never disagree with the header label. */ + key: string; + label: string; + sourceProduct: string; + signals: Signal[]; +} + +/** Minimum findings before the grouped view engages. */ +export const GROUPING_MIN_SIGNALS = 5; + +/** + * Bucket signals by their source line ("Product · Type"). Keying on the + * rendered label rather than raw source_product/source_type splits scout + * findings by skill instead of lumping every scout under one header. Groups + * are ordered largest first (ties keep first-appearance order); the incoming + * signal order is preserved within each group. + */ +export function groupSignalsByType(signals: Signal[]): SignalGroup[] { + const groups = new Map(); + for (const signal of signals) { + const label = signalCardSourceLine({ + ...signal, + extra: parseExtra(signal.extra), + }); + const group = groups.get(label); + if (group) { + group.signals.push(signal); + } else { + groups.set(label, { + key: label, + label, + sourceProduct: signal.source_product, + signals: [signal], + }); + } + } + return [...groups.values()].sort( + (a, b) => b.signals.length - a.signals.length, + ); +} + +/** + * The grouped view only helps when it actually compresses the list: enough + * findings to be worth scanning by type, and at least one type that repeats. + * Otherwise headers are pure overhead over the flat list. + */ +export function shouldGroupSignals( + groups: SignalGroup[], + signalCount: number, +): boolean { + return signalCount >= GROUPING_MIN_SIGNALS && groups.length < signalCount; +}