Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<typeof SignalsList> = {
title: "Inbox/SignalsList",
component: SignalsList,
decorators: [
(Story) => (
<div style={{ maxWidth: 420 }}>
<Story />
</div>
),
],
};

export default meta;
type Story = StoryObj<typeof SignalsList>;

let seq = 0;
function signal(overrides: Partial<Signal>): 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" },
}),
],
},
};
Original file line number Diff line number Diff line change
@@ -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 (
<Flex direction="column" gap="2">
{signals.map((signal) => (
<SignalCard key={signal.signal_id} signal={signal} />
))}
</Flex>
);
}

return (
<Flex direction="column" gap="2">
{signals.map((signal) => (
<SignalCard key={signal.signal_id} signal={signal} />
{groups.map((group) => (
<SignalGroupSection key={group.key} group={group} />
))}
</Flex>
);
}

function SignalGroupSection({ group }: { group: SignalGroup }) {
const [expanded, setExpanded] = useState(false);
const meta = getSourceProductMeta(group.sourceProduct);

return (
<Box className="min-w-0 overflow-hidden rounded-(--radius-2) border border-(--gray-6) bg-gray-1">
<button
type="button"
aria-expanded={expanded}
onClick={() => setExpanded((v) => !v)}
className="flex w-full cursor-default items-center gap-2 p-3 text-left hover:bg-gray-2"
>
{expanded ? (
<CaretDownIcon size={12} className="shrink-0 text-gray-10" />
) : (
<CaretRightIcon size={12} className="shrink-0 text-gray-10" />
)}
<span
className="shrink-0"
style={{ color: meta?.color ?? "var(--gray-9)" }}
>
{meta ? (
<meta.Icon size={14} />
) : (
<span className="inline-block h-2.5 w-2.5 rounded-full bg-(--gray-9)" />
)}
</span>
<Text className="font-medium text-[13px] text-gray-11">
{group.label}
</Text>
<span className="flex-1" />
<Badge variant="soft" color="gray" size="1" className="text-[11px]">
{group.signals.length}
</Badge>
</button>
{expanded && (
<Flex direction="column" gap="2" className="px-3 pb-3">
{group.signals.map((signal) => (
<SignalCard key={signal.signal_id} signal={signal} />
))}
</Flex>
)}
</Box>
);
}

/**
* 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -22,89 +23,6 @@ import {

const COLLAPSE_THRESHOLD = 300;

// ── Source line labels (matching PostHog Cloud's signalCardSourceLine) ────────

const ERROR_TRACKING_TYPE_LABELS: Record<string, string> = {
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, unknown>;
}): 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 {
Expand Down Expand Up @@ -207,17 +125,6 @@ function zendeskWebUrl(apiUrl: string): string {
return `${origin}/agent/tickets/${id}`;
}

function parseExtra(raw: Record<string, unknown>): Record<string, unknown> {
if (typeof raw === "string") {
try {
return JSON.parse(raw) as Record<string, unknown>;
} catch {
return {};
}
}
return raw;
}

// ── Type guards ──────────────────────────────────────────────────────────────

function isGithubIssueExtra(
Expand Down
Loading