Skip to content
Open
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
7 changes: 7 additions & 0 deletions packages/core/src/inbox/reportFiltering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ import type {
export const INBOX_PIPELINE_STATUS_FILTER =
"potential,candidate,in_progress,ready,pending_input,failed";

/**
* Status filter for the Dismissed tab. Suppressed reports are excluded from the
* main pipeline query, so the Dismissed tab fetches them explicitly. `deleted`
* is terminal and stripped server-side, so it is never listed here.
*/
export const INBOX_DISMISSED_STATUS_FILTER = "suppressed";

/** Polling interval for inbox queries while the Electron window is focused. */
export const INBOX_REFETCH_INTERVAL_MS = 3000;

Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/inbox/reportMembership.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
INBOX_SCOPE_ENTIRE_PROJECT,
INBOX_SCOPE_FOR_YOU,
isAgentRunReport,
isDismissedReport,
isExcludedFromInbox,
isInboxDetailPath,
isPullRequestReport,
Expand Down Expand Up @@ -36,6 +37,24 @@ function fakeReport(overrides: Partial<SignalReport> = {}): SignalReport {
};
}

describe("isDismissedReport", () => {
it("matches only suppressed reports", () => {
expect(isDismissedReport(fakeReport({ status: "suppressed" }))).toBe(true);
});

it.each([
"potential",
"candidate",
"in_progress",
"pending_input",
"ready",
"failed",
"deleted",
] as const)("does not match %s reports", (status) => {
expect(isDismissedReport(fakeReport({ status }))).toBe(false);
});
});

describe("isInboxDetailPath", () => {
it("matches detail paths for each inbox tab", () => {
expect(isInboxDetailPath("/code/inbox/pulls/abc")).toBe(true);
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/inbox/reportMembership.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ export function isExcludedFromInbox(report: SignalReport): boolean {
return INBOX_EXCLUDED_STATUSES.has(report.status);
}

/**
* Dismissed tab membership: reports the user suppressed from the inbox. The
* dismiss action sets `suppressed`; `deleted` is terminal and never listed.
* These reports are fetched by a dedicated query (the main pipeline query
* excludes them), so this predicate is applied to that separate list.
*/
export function isDismissedReport(report: SignalReport): boolean {
return report.status === "suppressed";
}

export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`;

export const INBOX_SCOPE_FOR_YOU: InboxScope = "for-you";
Expand Down Expand Up @@ -62,14 +72,20 @@ export function countInboxScopeReports(
return reports.filter((report) => matchesInboxScope(report, scope)).length;
}

export type InboxTabKey = "pulls" | "reports" | "runs";
export type InboxTabKey = "pulls" | "reports" | "runs" | "dismissed";

export const INBOX_TAB_KEYS: InboxTabKey[] = ["pulls", "reports", "runs"];
export const INBOX_TAB_KEYS: InboxTabKey[] = [
"pulls",
"reports",
"runs",
"dismissed",
];

export const INBOX_TAB_LABEL: Record<InboxTabKey, string> = {
pulls: "Pull requests",
reports: "Reports",
runs: "Runs",
dismissed: "Dismissed",
};

/**
Expand All @@ -87,6 +103,7 @@ export const INBOX_TAB_LIST_ROUTE: Record<
pulls: "/code/inbox/pulls",
reports: "/code/inbox/reports",
runs: "/code/inbox/runs",
dismissed: "/code/inbox/dismissed",
};

const INBOX_DETAIL_PATH_RE = new RegExp(
Expand Down
28 changes: 28 additions & 0 deletions packages/shared/src/dismissal-reasons.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import {
DISMISSAL_REASON_OPTIONS,
dismissalReasonLabel,
isDismissalReasonSnooze,
} from "./dismissal-reasons";

describe("dismissalReasonLabel", () => {
it.each(DISMISSAL_REASON_OPTIONS)(
"maps known reason $value to its label",
({ value, label }) => {
expect(dismissalReasonLabel(value)).toBe(label);
},
);

it("falls back to the raw code for an unrecognised reason", () => {
expect(dismissalReasonLabel("some_brand_new_code")).toBe(
"some_brand_new_code",
);
});
});

describe("isDismissalReasonSnooze", () => {
it("is true for already_fixed and false for a permanent dismissal", () => {
expect(isDismissalReasonSnooze("already_fixed")).toBe(true);
expect(isDismissalReasonSnooze("wontfix_intentional")).toBe(false);
});
});
11 changes: 11 additions & 0 deletions packages/shared/src/dismissal-reasons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,14 @@ export function isDismissalReasonSnooze(
option.snoozesInsteadOfDismiss === true
);
}

/**
* Human label for a persisted dismissal reason code. Reason codes are owned by
* the client that dismissed the report, so an unrecognised value (e.g. from a
* newer client) falls back to the raw code rather than being dropped.
*/
export function dismissalReasonLabel(value: string): string {
return (
DISMISSAL_REASON_OPTIONS.find((o) => o.value === value)?.label ?? value
);
}
1 change: 1 addition & 0 deletions packages/shared/src/dismissalReasons.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export {
DISMISSAL_REASON_OPTIONS,
type DismissalReasonOptionValue,
dismissalReasonLabel,
isDismissalReasonSnooze,
} from "./dismissal-reasons";
4 changes: 4 additions & 0 deletions packages/shared/src/domain-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,10 @@ export interface SignalReport {
actionability?: SignalReportActionability | null;
/** Whether the issue appears already fixed, from the actionability judgment artefact. */
already_addressed?: boolean | null;
/** Reason code from the latest dismissal artefact, set when the report was suppressed. */
dismissal_reason?: DismissalReasonOptionValue | null;
/** Free-form note captured alongside the dismissal reason. */
dismissal_note?: string | 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. */
Expand Down
23 changes: 22 additions & 1 deletion packages/ui/src/features/inbox/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,36 @@ The main objects are:

## Information Architecture

Inbox has three tabs and one reviewer-scope control:
Inbox has four tabs and one reviewer-scope control:

| Tab | Route | Membership |
| --- | --- | --- |
| Pull requests | `/code/inbox/pulls` | Reports with `implementation_pr_url` set |
| Reports | `/code/inbox/reports` | Reports without a PR and not currently running |
| Runs | `/code/inbox/runs` | Reports that are still in progress or waiting on input |
| Dismissed | `/code/inbox/dismissed` | Reports the user suppressed (`status === "suppressed"`) |

Detail pages live under the same tab: `/code/inbox/<tab>/$reportId`.

The Dismissed tab is the exception: suppressed reports are excluded from the
main pipeline query, so the tab fetches them with a dedicated `status=suppressed`
query (`useInboxDismissedReports`). Its detail view (`DismissedReportDetail`) is
read-only — summary + evidence + a single Restore action, no triage affordances —
and depends on the backend serving suppressed reports on the `retrieve`/`signals`
read paths (PostHog/posthog#64019). Restore uses `useInboxRestoreReport`, which
reuses the `state` action's `potential` ("reopen") transition — the only reopen
path the backend exposes. The reviewer scope control is hidden on this tab since
the dismissed list is not scoped, and the tab carries no count badge. The
Dismissed detail is **not** a tracked `InboxDetailTab` (no OPENED/CLOSED
engagement events), since its rank would be measured against the wrong list.

Each `DismissedReportCard` shows why the report was suppressed (`dismissal_reason`,
labelled via `dismissalReasonLabel`, with `dismissal_note` as a tooltip). These
are denormalised onto the list `SignalReport` by the backend serializer — the
same artefact-lift pattern as `priority`/`actionability`/`already_addressed` —
so cards avoid an N+1 per-card artefact fetch. Unknown reason codes fall back to
the raw value; cards with no dismissal artefact simply omit the chip.

Responder configuration is **not** an Inbox tab. It is the top-level Responders sidebar item at `/code/agents`. The legacy `/code/inbox/agents` route redirects there.

Reviewer scope is a UI preference stored in `inboxReviewerScopeStore`. It filters the list between reports suggested for the current user and reports for someone else. It does not change tab membership; the tab predicates are independent.
Expand All @@ -49,6 +69,7 @@ The tab components are intentionally simple:
- `PullRequestsTab` partitions scoped reports with `isPullRequestReport`.
- `ReportsTab` partitions with `isReportTabReport`.
- `RunsTab` partitions with `isAgentRunReport`.
- `DismissedTab` lists its own `useInboxDismissedReports` query (matching `isDismissedReport`); no detail route, restore action per card.

The detail components share the same shape: load the report, render a common header, then render tab-specific sections. Detail sections should explain the report in product terms, not expose backend object names.

Expand Down
157 changes: 157 additions & 0 deletions packages/ui/src/features/inbox/components/DismissedReportCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
ArrowCounterClockwiseIcon,
LightningIcon,
} from "@phosphor-icons/react";
import {
deriveHeadline,
displayConventionalCommitTitle,
parseConventionalCommitTitle,
} from "@posthog/core/inbox/reportPresentation";
import { cn } from "@posthog/quill";
import { dismissalReasonLabel } from "@posthog/shared/dismissalReasons";
import type { SignalReport } from "@posthog/shared/types";
import { ConventionalCommitScopeTag } from "@posthog/ui/features/inbox/components/ConventionalCommitScopeTag";
import { InboxCardSourceMeta } from "@posthog/ui/features/inbox/components/InboxCardSourceMeta";
import { InboxCardTitle } from "@posthog/ui/features/inbox/components/InboxCardTitle";
import { PriorityMonogram } from "@posthog/ui/features/inbox/components/PriorityMonogram";
import { hasKnownSourceProduct } from "@posthog/ui/features/inbox/components/utils/source-product-icons";
import { Button as UiButton } from "@posthog/ui/primitives/Button";
import { Flex, Text } from "@radix-ui/themes";
import { Link } from "@tanstack/react-router";

interface DismissedReportCardProps {
report: SignalReport;
onRestore: () => void;
isRestorePending: boolean;
}

/**
* Card for the Dismissed tab. Links into the read-only dismissed detail view;
* the Restore button (right column) stops propagation so it doesn't navigate.
*/
export function DismissedReportCard({
report,
onRestore,
isRestorePending,
}: DismissedReportCardProps) {
const hasSource = hasKnownSourceProduct(report.source_products);
const dismissedAtRaw = report.updated_at ?? report.created_at;
const dismissedAtDate = dismissedAtRaw ? new Date(dismissedAtRaw) : null;
const dismissedAtLabel =
dismissedAtDate && !Number.isNaN(dismissedAtDate.getTime())
? dismissedAtDate.toLocaleDateString(undefined, {
month: "short",
day: "numeric",
})
: null;
const conventionalTitle = parseConventionalCommitTitle(report.title);
const cardTitle = displayConventionalCommitTitle(
report.title,
"Untitled report",
);
const headline = deriveHeadline(report.summary);
const reasonLabel = report.dismissal_reason
? dismissalReasonLabel(report.dismissal_reason)
: null;
const dismissalNote = report.dismissal_note?.trim() || null;

return (
<div
className={cn(
"group flex w-full items-stretch gap-3 rounded-(--radius-2) border border-(--gray-6) border-dashed bg-(--color-panel-solid) px-4 py-3.5 opacity-90 transition duration-150 hover:border-(--gray-7) hover:bg-(--gray-2)",
)}
>
<Link
to="/code/inbox/dismissed/$reportId"
params={{ reportId: report.id }}
preload="intent"
className="flex min-w-0 flex-1 items-start gap-3 text-left text-inherit no-underline focus-visible:outline-none"
>
<PriorityMonogram priority={report.priority} />

<Flex direction="column" gap="1.5" className="min-w-0 flex-1">
<Flex align="center" gap="1" wrap="wrap" className="min-w-0">
{conventionalTitle && (
<ConventionalCommitScopeTag
type={conventionalTitle.type}
scope={conventionalTitle.scope}
compact
/>
)}
<InboxCardTitle>{cardTitle}</InboxCardTitle>
</Flex>

{headline && (
<Text className="wrap-break-word mt-0.5 line-clamp-2 text-[12.5px] text-gray-10 leading-snug">
{headline}
</Text>
)}

{(!!hasSource || dismissedAtLabel || reasonLabel) && (
<Flex align="center" wrap="wrap" className="mt-1.5 min-w-0 gap-2.5">
<InboxCardSourceMeta
repoSlug={null}
sourceProducts={report.source_products}
className=""
/>
{dismissedAtLabel && (
<Text className="text-[12px] text-gray-10">
Dismissed {dismissedAtLabel}
</Text>
)}
{reasonLabel && (
<Text
className="max-w-full truncate rounded-(--radius-1) bg-(--gray-3) px-1.5 py-0.5 text-[11px] text-gray-11"
title={
dismissalNote
? `${reasonLabel} — ${dismissalNote}`
: reasonLabel
}
>
{reasonLabel}
</Text>
)}
</Flex>
)}
</Flex>
</Link>

<Flex
direction="column"
align="end"
justify="between"
className="shrink-0 border-border border-l pl-3"
>
<UiButton
type="button"
variant="soft"
color="gray"
size="1"
aria-label="Restore this report to the inbox"
tooltipContent="Restore to inbox"
loading={isRestorePending}
disabled={isRestorePending}
onClick={(event) => {
event.stopPropagation();
onRestore();
}}
>
<ArrowCounterClockwiseIcon size={14} />
Restore
</UiButton>

<Flex
align="center"
gap="1"
className="shrink-0 text-[12px] text-gray-10"
>
<LightningIcon size={11} />
<span className="tabular-nums">
{report.signal_count} finding
{report.signal_count !== 1 ? "s" : ""}
</span>
</Flex>
</Flex>
</div>
);
}
Loading
Loading