From 69a711131f5b8551281922b8d5d81565471bd607 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 25 Jun 2026 20:10:14 +0100 Subject: [PATCH 1/3] Add Not actionable tab and route resolved PRs to Archive in inbox MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replicates the PostHog/posthog inbox tab structure in the PostHog Code desktop app: - New staff-only **Not actionable** tab listing reports the agent judged `not_actionable`; these are pulled out of the Reports tab so they no longer double-list. - **Archive** now includes `resolved` reports (merged implementation PR) alongside user-suppressed ones. Resolved reports show for reference only, with no Restore action. This gives merged PRs a home — previously they fell off every tab once merged. - Adds `resolved` to the `SignalReportStatus` enum and its labels/badges. - **Runs** already mirrored posthog (Queued / Live / Recently finished), so it's unchanged. Generated-By: PostHog Code Task-Id: 3a7dec81-6f1d-425a-a7cb-f468c36b3ec7 --- packages/core/src/inbox/reportFiltering.ts | 15 ++-- .../core/src/inbox/reportMembership.test.ts | 81 ++++++++++++++++++- packages/core/src/inbox/reportMembership.ts | 60 ++++++++++++-- packages/core/src/inbox/reportPresentation.ts | 4 + packages/core/src/inbox/statusLabels.ts | 2 + packages/shared/src/signal-types.ts | 1 + packages/ui/src/features/inbox/CLAUDE.md | 42 ++++++---- .../inbox/components/DismissedTab.tsx | 14 ++-- .../inbox/components/InboxDetailFrame.tsx | 6 +- .../components/InboxReportDetailGate.tsx | 9 +++ .../features/inbox/components/InboxTabBar.tsx | 41 +++++++--- .../inbox/components/NotActionableTab.tsx | 39 +++++++++ .../features/inbox/components/ReportCard.tsx | 65 ++++++++++----- .../inbox/components/ReportDetail.tsx | 35 ++++++-- .../utils/SignalReportStatusBadge.tsx | 3 + .../inbox/hooks/useInboxDismissedReports.ts | 11 +-- .../hooks/useInboxReportDetailPrefetch.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 75 +++++++++++++++++ .../code/inbox/not-actionable.$reportId.tsx | 24 ++++++ .../code/inbox/not-actionable.index.tsx | 6 ++ .../routes/code/inbox/not-actionable.tsx | 5 ++ 21 files changed, 460 insertions(+), 82 deletions(-) create mode 100644 packages/ui/src/features/inbox/components/NotActionableTab.tsx create mode 100644 packages/ui/src/router/routes/code/inbox/not-actionable.$reportId.tsx create mode 100644 packages/ui/src/router/routes/code/inbox/not-actionable.index.tsx create mode 100644 packages/ui/src/router/routes/code/inbox/not-actionable.tsx diff --git a/packages/core/src/inbox/reportFiltering.ts b/packages/core/src/inbox/reportFiltering.ts index 170b65db17..42471ba0de 100644 --- a/packages/core/src/inbox/reportFiltering.ts +++ b/packages/core/src/inbox/reportFiltering.ts @@ -13,14 +13,15 @@ export const INBOX_PIPELINE_STATUS_FILTER = "potential,candidate,in_progress,ready,pending_input,failed"; /** - * Status filter for the Archive tab. `suppressed` is the only archived status: - * it is the single state the archive action sets, and the only not-in-inbox - * state worth restoring. `deleted` is permanent and stripped server-side; snooze - * is a temporary `snoozed_until` timestamp, not a status, and auto-returns. See - * `isDismissedReport` for the full rationale. Suppressed reports are excluded - * from the main pipeline query, so the Archive tab fetches them explicitly. + * Status filter for the Archive tab. Two terminal states land here: `suppressed` + * (the user archived the report — restorable) and `resolved` (its implementation + * PR merged — terminal, shown for reference only). `deleted` is permanent and + * stripped server-side; snooze is a temporary `snoozed_until` timestamp, not a + * status, and auto-returns. See `isDismissedReport` for the full rationale. Both + * states are excluded from the main pipeline query, so the Archive tab fetches + * them explicitly. */ -export const INBOX_DISMISSED_STATUS_FILTER = "suppressed"; +export const INBOX_DISMISSED_STATUS_FILTER = "suppressed,resolved"; /** * Status filter for the Pull requests tab's list and count. Only `ready` PRs — diff --git a/packages/core/src/inbox/reportMembership.test.ts b/packages/core/src/inbox/reportMembership.test.ts index d7c1b4e894..a0e6334d44 100644 --- a/packages/core/src/inbox/reportMembership.test.ts +++ b/packages/core/src/inbox/reportMembership.test.ts @@ -10,8 +10,11 @@ import { isDismissedReport, isExcludedFromInbox, isInboxDetailPath, + isNotActionableReport, isPullRequestReport, isReportTabReport, + isResolvedReport, + isStaffOnlyInboxTab, matchesReviewerScope, partitionRunsTabReports, teammateInboxScope, @@ -38,8 +41,9 @@ function fakeReport(overrides: Partial = {}): SignalReport { } describe("isDismissedReport", () => { - it("matches only suppressed reports", () => { + it("matches suppressed and resolved reports", () => { expect(isDismissedReport(fakeReport({ status: "suppressed" }))).toBe(true); + expect(isDismissedReport(fakeReport({ status: "resolved" }))).toBe(true); }); it.each([ @@ -55,10 +59,68 @@ describe("isDismissedReport", () => { }); }); +describe("isResolvedReport", () => { + it("matches only resolved reports", () => { + expect(isResolvedReport(fakeReport({ status: "resolved" }))).toBe(true); + expect(isResolvedReport(fakeReport({ status: "suppressed" }))).toBe(false); + expect(isResolvedReport(fakeReport({ status: "ready" }))).toBe(false); + }); +}); + +describe("isNotActionableReport", () => { + it("matches reports the judgment marked not_actionable", () => { + expect( + isNotActionableReport(fakeReport({ actionability: "not_actionable" })), + ).toBe(true); + }); + + it.each(["immediately_actionable", "requires_human_input", null] as const)( + "does not match %s actionability", + (actionability) => { + expect(isNotActionableReport(fakeReport({ actionability }))).toBe(false); + }, + ); + + it("excludes terminal (suppressed/resolved/deleted) reports", () => { + expect( + isNotActionableReport( + fakeReport({ actionability: "not_actionable", status: "suppressed" }), + ), + ).toBe(false); + expect( + isNotActionableReport( + fakeReport({ actionability: "not_actionable", status: "resolved" }), + ), + ).toBe(false); + }); + + it("excludes PR-bearing reports", () => { + expect( + isNotActionableReport( + fakeReport({ + actionability: "not_actionable", + implementation_pr_url: "https://gh/p/1", + }), + ), + ).toBe(false); + }); +}); + +describe("isStaffOnlyInboxTab", () => { + it("gates only the Not actionable tab", () => { + expect(isStaffOnlyInboxTab("not-actionable")).toBe(true); + expect(isStaffOnlyInboxTab("reports")).toBe(false); + expect(isStaffOnlyInboxTab("pulls")).toBe(false); + expect(isStaffOnlyInboxTab("runs")).toBe(false); + expect(isStaffOnlyInboxTab("dismissed")).toBe(false); + }); +}); + describe("isInboxDetailPath", () => { it("matches detail paths for each inbox tab", () => { expect(isInboxDetailPath("/code/inbox/pulls/abc")).toBe(true); expect(isInboxDetailPath("/code/inbox/reports/abc")).toBe(true); + expect(isInboxDetailPath("/code/inbox/not-actionable/abc")).toBe(true); expect(isInboxDetailPath("/code/inbox/runs/abc")).toBe(true); }); @@ -158,7 +220,10 @@ describe("tabFilters", () => { }); describe("isExcludedFromInbox", () => { - it("returns true for suppressed and deleted", () => { + it("returns true for resolved, suppressed and deleted", () => { + expect(isExcludedFromInbox(fakeReport({ status: "resolved" }))).toBe( + true, + ); expect(isExcludedFromInbox(fakeReport({ status: "suppressed" }))).toBe( true, ); @@ -230,6 +295,18 @@ describe("tabFilters", () => { ), ).toBe(false); }); + + it("excludes not-actionable reports (they go to the Not actionable tab)", () => { + expect( + isReportTabReport( + fakeReport({ status: "ready", actionability: "not_actionable" }), + ), + ).toBe(false); + }); + + it("excludes resolved reports (they go to the Archive tab)", () => { + expect(isReportTabReport(fakeReport({ status: "resolved" }))).toBe(false); + }); }); describe("matchesReviewerScope", () => { diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index 0ff36c718d..1f56189a35 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -7,6 +7,7 @@ import type { SignalReport } from "@posthog/shared/types"; * them out via their own predicates. */ export const INBOX_EXCLUDED_STATUSES = new Set([ + "resolved", "suppressed", "deleted", ]); @@ -16,10 +17,10 @@ export function isExcludedFromInbox(report: SignalReport): boolean { } /** - * Archive tab membership. `suppressed` is the only status that represents "the - * user archived this out of the inbox" — there is no separate `dismissed` / - * `resolved` status in the enum (see `SignalReportStatus`), the archive action - * sets `suppressed`. The other not-in-inbox states are deliberately excluded: + * Archive tab membership. Two terminal states live here: `suppressed` (the user + * archived the report out of the inbox — restorable) and `resolved` (the + * report's implementation PR was merged — terminal, shown for reference only, + * not restorable). The other not-in-inbox state is deliberately excluded: * `deleted` is permanent (gone, never restorable, stripped server-side), and * snooze is not a status at all — it is a temporary `snoozed_until` timestamp * on an otherwise-active report that auto-returns to the inbox when it elapses, @@ -28,7 +29,16 @@ export function isExcludedFromInbox(report: SignalReport): boolean { * predicate is applied to that separate list. */ export function isDismissedReport(report: SignalReport): boolean { - return report.status === "suppressed"; + return report.status === "suppressed" || report.status === "resolved"; +} + +/** + * A report whose implementation PR has been merged. It lands in the Archive tab + * for reference but, unlike a user-suppressed report, has no Restore action — + * the work is finished, not paused. + */ +export function isResolvedReport(report: SignalReport): boolean { + return report.status === "resolved"; } export type InboxScope = "for-you" | "entire-project" | `teammate:${string}`; @@ -78,11 +88,17 @@ export function countInboxScopeReports( return reports.filter((report) => matchesInboxScope(report, scope)).length; } -export type InboxTabKey = "pulls" | "reports" | "runs" | "dismissed"; +export type InboxTabKey = + | "pulls" + | "reports" + | "not-actionable" + | "runs" + | "dismissed"; export const INBOX_TAB_KEYS: InboxTabKey[] = [ "pulls", "reports", + "not-actionable", "runs", "dismissed", ]; @@ -90,10 +106,25 @@ export const INBOX_TAB_KEYS: InboxTabKey[] = [ export const INBOX_TAB_LABEL: Record = { pulls: "Pull requests", reports: "Reports", + "not-actionable": "Not actionable", runs: "Runs", dismissed: "Archive", }; +/** + * Tabs only shown to staff (internal) users. Non-staff see Pull requests, + * Reports, Runs and Archive. The Not actionable tab is an internal signal-quality + * audit surface — reports the agent judged not worth acting on — so it stays + * behind the staff flag, matching the PostHog Cloud inbox. + */ +export const INBOX_STAFF_ONLY_TAB_KEYS = new Set([ + "not-actionable", +]); + +export function isStaffOnlyInboxTab(key: InboxTabKey): boolean { + return INBOX_STAFF_ONLY_TAB_KEYS.has(key); +} + /** * Canonical inbox tab list routes. Use these constants instead of hard-coding * `/code/inbox/pulls` etc., so renames stay in one place. @@ -108,6 +139,7 @@ export const INBOX_TAB_LIST_ROUTE: Record< > = { pulls: "/code/inbox/pulls", reports: "/code/inbox/reports", + "not-actionable": "/code/inbox/not-actionable", runs: "/code/inbox/runs", dismissed: "/code/inbox/dismissed", }; @@ -227,9 +259,25 @@ export function isReportTabReport(report: SignalReport): boolean { // rather than reappearing as a Report. if (report.implementation_pr_url) return false; if (isAgentRunReport(report)) return false; + // Reports the agent judged not worth acting on get their own (staff-only) + // tab and are kept out of the main Reports list, matching the Cloud inbox. + if (isNotActionableReport(report)) return false; return true; } +/** + * Not-actionable tab membership: reports the agentic actionability judgment + * marked `not_actionable`. A staff-only signal-quality audit surface. These are + * still in-pipeline reports (the judgment is an artefact, not a status), so this + * partitions the same main list the other report tabs read — it just keeps them + * out of the Reports tab via the check in `isReportTabReport`. + */ +export function isNotActionableReport(report: SignalReport): boolean { + if (isExcludedFromInbox(report)) return false; + if (report.implementation_pr_url) return false; + return report.actionability === "not_actionable"; +} + export function matchesReviewerScope( report: SignalReport, scope: InboxScope, diff --git a/packages/core/src/inbox/reportPresentation.ts b/packages/core/src/inbox/reportPresentation.ts index b2718396ba..26094eebc9 100644 --- a/packages/core/src/inbox/reportPresentation.ts +++ b/packages/core/src/inbox/reportPresentation.ts @@ -60,6 +60,8 @@ export function inboxStatusLabel(status: SignalReportStatus): string { return "Gathering"; case "failed": return "Failed"; + case "resolved": + return "Resolved"; case "suppressed": return "Suppressed"; case "deleted": @@ -83,6 +85,8 @@ export function inboxStatusAccentCss(status: SignalReportStatus): string { return "var(--gray-9)"; case "failed": return "var(--red-9)"; + case "resolved": + return "var(--green-9)"; default: return "var(--gray-8)"; } diff --git a/packages/core/src/inbox/statusLabels.ts b/packages/core/src/inbox/statusLabels.ts index c787db3665..51b4f51937 100644 --- a/packages/core/src/inbox/statusLabels.ts +++ b/packages/core/src/inbox/statusLabels.ts @@ -14,6 +14,8 @@ export function inboxStatusLabel(status: SignalReportStatus): string { return "Gathering"; case "failed": return "Failed"; + case "resolved": + return "Resolved"; case "suppressed": return "Suppressed"; case "deleted": diff --git a/packages/shared/src/signal-types.ts b/packages/shared/src/signal-types.ts index b7cb8e38d6..526a7f044f 100644 --- a/packages/shared/src/signal-types.ts +++ b/packages/shared/src/signal-types.ts @@ -5,6 +5,7 @@ export type SignalReportStatus = | "ready" | "failed" | "pending_input" + | "resolved" | "suppressed" | "deleted"; diff --git a/packages/ui/src/features/inbox/CLAUDE.md b/packages/ui/src/features/inbox/CLAUDE.md index 9f01dc0cd1..a8495254af 100644 --- a/packages/ui/src/features/inbox/CLAUDE.md +++ b/packages/ui/src/features/inbox/CLAUDE.md @@ -15,29 +15,43 @@ The main objects are: ## Information Architecture -Inbox has four tabs and one reviewer-scope control: +Inbox has five 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 | +| Reports | `/code/inbox/reports` | Reports without a PR, not running, and not judged not-actionable | +| Not actionable | `/code/inbox/not-actionable` | Reports the judgment marked `actionability === "not_actionable"` (**staff-only**) | | Runs | `/code/inbox/runs` | Reports that are still in progress or waiting on input | -| Archive | `/code/inbox/dismissed` | Reports the user archived/suppressed (`status === "suppressed"`) | +| Archive | `/code/inbox/dismissed` | Terminal reports: user-archived (`suppressed`) + merged-PR (`resolved`) | + +The **Not actionable** tab is gated to staff (internal) users via +`INBOX_STAFF_ONLY_TAB_KEYS` (see `isStaffOnlyInboxTab`); `InboxTabBar` hides it +for non-staff, keyed off `user.is_staff`. It's an internal signal-quality audit +surface and mirrors the same staff-only tab in `PostHog/posthog`. It reuses the +shared `InboxReportListTab` shell (same as Reports/Pulls); cards link to its own +detail route so back-navigation stays on the tab. `isReportTabReport` excludes +not-actionable reports so they don't double-list in Reports. Detail pages live under the same tab: `/code/inbox//$reportId`. The Archive tab (route `/code/inbox/dismissed`, user-facing label "Archive") 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 archive list is not scoped, and the tab carries no count badge. The -Archive detail is **not** a tracked `InboxDetailTab` (no OPENED/CLOSED -engagement events), since its rank would be measured against the wrong list. +the exception: it holds two terminal states — `suppressed` (the user archived +the report) and `resolved` (its implementation PR merged). Both are excluded from +the main pipeline query, so the tab fetches them with a dedicated +`status=suppressed,resolved` query (`useInboxDismissedReports`). Suppressed +reports carry a Restore action (`useInboxRestoreReport`, which reuses the `state` +action's `potential` "reopen" transition — the only reopen path the backend +exposes); resolved reports are terminal and shown for reference only, with no +Restore (`isResolvedReport` gates the row action in `ReportCard`). This is the +home for a report once its PR is merged — it used to fall off every tab. Its +detail view (`DismissedReportDetail`) is read-only — summary + evidence, no +triage affordances — and depends on the backend serving these reports on the +`retrieve`/`signals` read paths (PostHog/posthog#64019). The reviewer scope +control is hidden on this tab since the archive list is not scoped, and the tab +carries no count badge. The Archive detail is **not** a tracked `InboxDetailTab` +(no OPENED/CLOSED engagement events), since its rank would be measured against +the wrong list. The internal route segment, query key, and component/hook names keep the `dismissed`/`suppressed` vocabulary (the backend status is `suppressed`); only diff --git a/packages/ui/src/features/inbox/components/DismissedTab.tsx b/packages/ui/src/features/inbox/components/DismissedTab.tsx index 9d7472b799..ee7d462552 100644 --- a/packages/ui/src/features/inbox/components/DismissedTab.tsx +++ b/packages/ui/src/features/inbox/components/DismissedTab.tsx @@ -13,9 +13,10 @@ import { useInboxRestoreReport } from "@posthog/ui/features/inbox/hooks/useInbox import { Flex } from "@radix-ui/themes"; /** - * Archive tab: reports the user has archived (suppressed) from the inbox, - * newest first. Each card can be restored back into the pipeline, or opened in - * a read-only detail view (summary + evidence) — no triage affordances. + * Archive tab: terminal reports, newest first. Suppressed reports (the user + * archived them) can be restored back into the pipeline; resolved reports (their + * implementation PR merged) appear for reference only, with no Restore action. + * Each opens a read-only detail view (summary + evidence) — no triage affordances. */ export function DismissedTab() { const { reports, isLoading } = useInboxDismissedReports(); @@ -38,10 +39,11 @@ export function DismissedTab() { - No archived reports + Nothing archived - Reports you archive from your inbox show up here. You can restore - any of them back to the inbox. + Reports you archive from your inbox show up here, where you can + restore them. Reports resolved by a merged pull request also + appear here for reference. diff --git a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx index 0031823dfc..b265855ece 100644 --- a/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx +++ b/packages/ui/src/features/inbox/components/InboxDetailFrame.tsx @@ -27,7 +27,11 @@ import type { ComponentType, ReactNode } from "react"; interface InboxDetailFrameProps { report: SignalReport; /** List route for the back-link (e.g. "/code/inbox/pulls"). */ - backTo: "/code/inbox/pulls" | "/code/inbox/reports" | "/code/inbox/dismissed"; + backTo: + | "/code/inbox/pulls" + | "/code/inbox/reports" + | "/code/inbox/not-actionable" + | "/code/inbox/dismissed"; backLabel: string; /** * Whether to render the Dismiss button + dialog. Off for already-dismissed diff --git a/packages/ui/src/features/inbox/components/InboxReportDetailGate.tsx b/packages/ui/src/features/inbox/components/InboxReportDetailGate.tsx index e9a6534f5b..43203e3c58 100644 --- a/packages/ui/src/features/inbox/components/InboxReportDetailGate.tsx +++ b/packages/ui/src/features/inbox/components/InboxReportDetailGate.tsx @@ -1,4 +1,5 @@ import { + isNotActionableReport, isPullRequestReport, isReportTabReport, } from "@posthog/core/inbox/reportMembership"; @@ -20,6 +21,7 @@ interface InboxReportDetailGateProps { backTo: | "/code/inbox/pulls" | "/code/inbox/reports" + | "/code/inbox/not-actionable" | "/code/inbox/runs" | "/code/inbox/dismissed"; backLabel: string; @@ -30,6 +32,7 @@ interface InboxReportDetailGateProps { type InboxDetailRoute = | "/code/inbox/pulls/$reportId" | "/code/inbox/reports/$reportId" + | "/code/inbox/not-actionable/$reportId" | "/code/inbox/runs/$reportId" | "/code/inbox/dismissed/$reportId"; @@ -42,6 +45,8 @@ type InboxDetailRoute = */ function nonSuppressedDetailRoute(report: SignalReport): InboxDetailRoute { if (isPullRequestReport(report)) return "/code/inbox/pulls/$reportId"; + if (isNotActionableReport(report)) + return "/code/inbox/not-actionable/$reportId"; if (isReportTabReport(report)) return "/code/inbox/reports/$reportId"; return "/code/inbox/runs/$reportId"; } @@ -160,7 +165,11 @@ function tabFromBackTo( ): InboxDetailTab | null { if (backTo === "/code/inbox/pulls") return "pulls"; if (backTo === "/code/inbox/runs") return "runs"; + // Archive and the staff-only Not actionable tab aren't part of the triage + // funnel, so their detail opens aren't tracked (rank would be measured against + // the wrong list). if (backTo === "/code/inbox/dismissed") return null; + if (backTo === "/code/inbox/not-actionable") return null; return "reports"; } diff --git a/packages/ui/src/features/inbox/components/InboxTabBar.tsx b/packages/ui/src/features/inbox/components/InboxTabBar.tsx index ef130f3876..61b1881e53 100644 --- a/packages/ui/src/features/inbox/components/InboxTabBar.tsx +++ b/packages/ui/src/features/inbox/components/InboxTabBar.tsx @@ -4,8 +4,10 @@ import { INBOX_TAB_LIST_ROUTE, type InboxTabCounts, type InboxTabKey, + isStaffOnlyInboxTab, } from "@posthog/core/inbox/reportMembership"; import { Tabs, TabsList, TabsTrigger } from "@posthog/quill"; +import { useMeQuery } from "@posthog/ui/features/auth/useMeQuery"; import { InboxScopeSelect } from "@posthog/ui/features/inbox/components/InboxScopeSelect"; import { Flex } from "@radix-ui/themes"; import { useNavigate, useRouterState } from "@tanstack/react-router"; @@ -15,6 +17,11 @@ interface InboxTabBarProps { } function activeTabFromPath(pathname: string): InboxTabKey { + // Check "not-actionable" before "reports": the former is not a prefix of the + // latter, but keeping the more specific routes first guards against future + // overlaps. + if (pathname.startsWith(INBOX_TAB_LIST_ROUTE["not-actionable"])) + return "not-actionable"; if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.reports)) return "reports"; if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.runs)) return "runs"; if (pathname.startsWith(INBOX_TAB_LIST_ROUTE.dismissed)) return "dismissed"; @@ -25,6 +32,11 @@ export function InboxTabBar({ counts }: InboxTabBarProps) { const navigate = useNavigate(); const pathname = useRouterState({ select: (s) => s.location.pathname }); const activeKey = activeTabFromPath(pathname); + const { data: currentUser } = useMeQuery(); + const isStaff = currentUser?.is_staff === true; + const visibleTabKeys = INBOX_TAB_KEYS.filter( + (key) => isStaff || !isStaffOnlyInboxTab(key), + ); return ( @@ -39,7 +51,7 @@ export function InboxTabBar({ counts }: InboxTabBarProps) { variant="line" className="h-auto gap-0.5 [&_.quill-tabs__indicator]:transition-[transform,width]! [&_.quill-tabs__indicator]:duration-100! [&_.quill-tabs__indicator]:ease-out!" > - {INBOX_TAB_KEYS.map((key) => { + {visibleTabKeys.map((key) => { const isActive = key === activeKey; return ( {INBOX_TAB_LABEL[key]} - {/* Runs and the open-ended Archive don't get a running total — it adds no signal. */} - {key !== "runs" && key !== "dismissed" && counts[key] > 0 && ( - - {counts[key]} - - )} + {/* Runs, Not actionable, and the open-ended Archive don't get a running total — it adds no signal. */} + {key !== "runs" && + key !== "dismissed" && + key !== "not-actionable" && + counts[key] > 0 && ( + + {counts[key]} + + )} ); })} diff --git a/packages/ui/src/features/inbox/components/NotActionableTab.tsx b/packages/ui/src/features/inbox/components/NotActionableTab.tsx new file mode 100644 index 0000000000..8e31990ed9 --- /dev/null +++ b/packages/ui/src/features/inbox/components/NotActionableTab.tsx @@ -0,0 +1,39 @@ +import { InfoIcon } from "@phosphor-icons/react"; +import { isNotActionableReport } from "@posthog/core/inbox/reportMembership"; +import { InboxReportListTab } from "@posthog/ui/features/inbox/components/InboxReportListTab"; +import { + ReportCard, + type ReportCardProps, +} from "@posthog/ui/features/inbox/components/ReportCard"; + +// Link cards (and their back navigation) at the Not actionable tab rather than +// the Reports tab. +function NotActionableReportCard( + props: Extract, +) { + return ; +} + +/** + * Staff-only (internal) tab listing reports the agentic actionability judgment + * marked `not_actionable`. Same list shell as Pull requests / Reports — only the + * predicate differs — so the team can audit signal quality. These reports are + * kept out of the Reports tab (see `isReportTabReport`). + */ +export function NotActionableTab() { + return ( + + ); +} diff --git a/packages/ui/src/features/inbox/components/ReportCard.tsx b/packages/ui/src/features/inbox/components/ReportCard.tsx index 5289982e6d..65d0e0dc43 100644 --- a/packages/ui/src/features/inbox/components/ReportCard.tsx +++ b/packages/ui/src/features/inbox/components/ReportCard.tsx @@ -40,6 +40,12 @@ interface DefaultReportCardProps extends BaseReportCardProps { onDismiss: () => void; dismissDisabledReason?: string | null; isDismissPending?: boolean; + /** + * Which tab's detail route the card links to. Defaults to the Reports tab; the + * staff-only Not actionable tab passes its own key so detail navigation (and + * the active tab on the way back) stays on that tab. + */ + detailTab?: "reports" | "not-actionable"; } /** @@ -59,16 +65,25 @@ export type ReportCardProps = DefaultReportCardProps | ArchivedReportCardProps; export function ReportCard(props: ReportCardProps) { const { report, isSelected = false, onRowClick } = props; const isArchived = props.variant === "archived"; + // Resolved reports (merged implementation PR) share the Archive tab but are + // terminal: shown for reference only, with no Restore action. + const isResolved = report.status === "resolved"; + const detailTab = props.variant === "archived" ? "reports" : props.detailTab; const detailRoute = isArchived ? { to: "/code/inbox/dismissed/$reportId" as const, params: { reportId: report.id }, } - : { - to: "/code/inbox/reports/$reportId" as const, - params: { reportId: report.id }, - }; + : detailTab === "not-actionable" + ? { + to: "/code/inbox/not-actionable/$reportId" as const, + params: { reportId: report.id }, + } + : { + to: "/code/inbox/reports/$reportId" as const, + params: { reportId: report.id }, + }; const { prefetch, pointerHandlers } = useInboxReportDetailPrefetch( report, detailRoute, @@ -196,7 +211,7 @@ export function ReportCard(props: ReportCardProps) { <> {updatedAtLabel && ( - Archived {updatedAtLabel} + {isResolved ? "Resolved" : "Archived"} {updatedAtLabel} )} {reasonLabel && ( @@ -237,23 +252,29 @@ export function ReportCard(props: ReportCardProps) { className="shrink-0 border-border border-l pl-3" > {props.variant === "archived" ? ( - { - event.stopPropagation(); - props.onRestore(); - }} - > - - Restore - + isResolved ? ( + // Terminal: the implementation PR merged, so there's nothing to + // restore — keep the row action column empty. + + ) : ( + { + event.stopPropagation(); + props.onRestore(); + }} + > + + Restore + + ) ) : ( <> {updatedAtLabel && ( diff --git a/packages/ui/src/features/inbox/components/ReportDetail.tsx b/packages/ui/src/features/inbox/components/ReportDetail.tsx index a025e39b36..34cb0b5cd7 100644 --- a/packages/ui/src/features/inbox/components/ReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/ReportDetail.tsx @@ -12,34 +12,57 @@ import { ReportTasksSection } from "@posthog/ui/features/inbox/components/Report import { SuggestedReviewersSection } from "@posthog/ui/features/inbox/components/SuggestedReviewersSection"; import { copyInboxReportLink } from "@posthog/ui/features/inbox/utils/copyInboxReportLink"; +/** Tabs whose detail view renders a `ReportDetail`. */ +type ReportDetailBackTo = "/code/inbox/reports" | "/code/inbox/not-actionable"; + interface ReportDetailProps { reportId: string; cachedReport?: SignalReport | null; + /** Where the back link points. Defaults to the Reports tab. */ + backTo?: ReportDetailBackTo; + /** Label for the back link. Defaults to "Back to reports". */ + backLabel?: string; } export function ReportDetail({ reportId, cachedReport = null, + backTo = "/code/inbox/reports", + backLabel = "Back to reports", }: ReportDetailProps) { return ( - {(report) => } + {(report) => ( + + )} ); } -function ReportDetailContent({ report }: { report: SignalReport }) { +function ReportDetailContent({ + report, + backTo, + backLabel, +}: { + report: SignalReport; + backTo: ReportDetailBackTo; + backLabel: string; +}) { return ( diff --git a/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx b/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx index 77aaa3711a..086d412681 100644 --- a/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx +++ b/packages/ui/src/features/inbox/components/utils/SignalReportStatusBadge.tsx @@ -12,6 +12,7 @@ const STATUS_TOOLTIPS: Record = { potential: "Gathering findings. The report will be queued once enough evidence accumulates.", failed: "Research failed. The report may be retried automatically.", + resolved: "This report's implementation pull request was merged.", suppressed: "This report has been suppressed and is out of your inbox.", deleted: "This report has been deleted.", }; @@ -30,6 +31,8 @@ function inboxStatusBadgeVariant(status: SignalReportStatus): BadgeVariant { return "info"; case "failed": return "destructive"; + case "resolved": + return "success"; default: return "default"; } diff --git a/packages/ui/src/features/inbox/hooks/useInboxDismissedReports.ts b/packages/ui/src/features/inbox/hooks/useInboxDismissedReports.ts index 632f71266e..86c799750b 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxDismissedReports.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxDismissedReports.ts @@ -5,12 +5,13 @@ import { import { useInboxReportsInfinite } from "@posthog/ui/features/inbox/hooks/useInboxReports"; /** - * Dismissed (suppressed) reports for the Dismissed tab. These are excluded from - * the main pipeline query, so they get a dedicated fetch. + * Archived reports for the Archive tab: suppressed (user-archived) and resolved + * (merged implementation PR). Both are excluded from the main pipeline query, so + * they get a dedicated fetch. * - * No polling interval: the dismissed list changes only when the user dismisses - * or restores a report, and both paths invalidate `reportKeys.all`, which this - * query falls under. Newest-dismissed first via `updated_at` (last state change). + * No polling interval: the list changes only when a report is archived, restored, + * or resolved, and those paths invalidate `reportKeys.all`, which this query + * falls under. Newest first via `updated_at` (last state change). */ export function useInboxDismissedReports() { const query = useInboxReportsInfinite({ diff --git a/packages/ui/src/features/inbox/hooks/useInboxReportDetailPrefetch.ts b/packages/ui/src/features/inbox/hooks/useInboxReportDetailPrefetch.ts index a88b2def30..55779cc5dd 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxReportDetailPrefetch.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxReportDetailPrefetch.ts @@ -13,6 +13,10 @@ export type InboxDetailRoute = to: "/code/inbox/reports/$reportId"; params: { reportId: string }; } + | { + to: "/code/inbox/not-actionable/$reportId"; + params: { reportId: string }; + } | { to: "/code/inbox/runs/$reportId"; params: { reportId: string }; diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index ff628f8d80..fb606478d7 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -37,12 +37,14 @@ import { Route as CodeTasksTaskIdRouteImport } from './routes/code/tasks/$taskId import { Route as CodeInboxRunsRouteImport } from './routes/code/inbox/runs' import { Route as CodeInboxReportsRouteImport } from './routes/code/inbox/reports' import { Route as CodeInboxPullsRouteImport } from './routes/code/inbox/pulls' +import { Route as CodeInboxNotActionableRouteImport } from './routes/code/inbox/not-actionable' import { Route as CodeInboxDismissedRouteImport } from './routes/code/inbox/dismissed' import { Route as CodeInboxAgentsRouteImport } from './routes/code/inbox/agents' import { Route as CodeAgentsScoutsRouteImport } from './routes/code/agents/scouts' import { Route as CodeInboxRunsIndexRouteImport } from './routes/code/inbox/runs.index' import { Route as CodeInboxReportsIndexRouteImport } from './routes/code/inbox/reports.index' import { Route as CodeInboxPullsIndexRouteImport } from './routes/code/inbox/pulls.index' +import { Route as CodeInboxNotActionableIndexRouteImport } from './routes/code/inbox/not-actionable.index' import { Route as CodeInboxDismissedIndexRouteImport } from './routes/code/inbox/dismissed.index' import { Route as WebsiteChannelIdTasksTaskIdRouteImport } from './routes/website/$channelId/tasks/$taskId' import { Route as WebsiteChannelIdDashboardsDashboardIdRouteImport } from './routes/website/$channelId/dashboards/$dashboardId' @@ -50,6 +52,7 @@ import { Route as CodeTasksPendingKeyRouteImport } from './routes/code/tasks/pen import { Route as CodeInboxRunsReportIdRouteImport } from './routes/code/inbox/runs.$reportId' import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbox/reports.$reportId' import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' +import { Route as CodeInboxNotActionableReportIdRouteImport } from './routes/code/inbox/not-actionable.$reportId' import { Route as CodeInboxDismissedReportIdRouteImport } from './routes/code/inbox/dismissed.$reportId' import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' import { Route as CodeAgentsScoutsSkillNameIndexRouteImport } from './routes/code/agents/scouts.$skillName.index' @@ -194,6 +197,11 @@ const CodeInboxPullsRoute = CodeInboxPullsRouteImport.update({ path: '/pulls', getParentRoute: () => CodeInboxRoute, } as any) +const CodeInboxNotActionableRoute = CodeInboxNotActionableRouteImport.update({ + id: '/not-actionable', + path: '/not-actionable', + getParentRoute: () => CodeInboxRoute, +} as any) const CodeInboxDismissedRoute = CodeInboxDismissedRouteImport.update({ id: '/dismissed', path: '/dismissed', @@ -224,6 +232,12 @@ const CodeInboxPullsIndexRoute = CodeInboxPullsIndexRouteImport.update({ path: '/', getParentRoute: () => CodeInboxPullsRoute, } as any) +const CodeInboxNotActionableIndexRoute = + CodeInboxNotActionableIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeInboxNotActionableRoute, + } as any) const CodeInboxDismissedIndexRoute = CodeInboxDismissedIndexRouteImport.update({ id: '/', path: '/', @@ -262,6 +276,12 @@ const CodeInboxPullsReportIdRoute = CodeInboxPullsReportIdRouteImport.update({ path: '/$reportId', getParentRoute: () => CodeInboxPullsRoute, } as any) +const CodeInboxNotActionableReportIdRoute = + CodeInboxNotActionableReportIdRouteImport.update({ + id: '/$reportId', + path: '/$reportId', + getParentRoute: () => CodeInboxNotActionableRoute, + } as any) const CodeInboxDismissedReportIdRoute = CodeInboxDismissedReportIdRouteImport.update({ id: '/$reportId', @@ -304,6 +324,7 @@ export interface FileRoutesByFullPath { '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/dismissed': typeof CodeInboxDismissedRouteWithChildren + '/code/inbox/not-actionable': typeof CodeInboxNotActionableRouteWithChildren '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren '/code/inbox/reports': typeof CodeInboxReportsRouteWithChildren '/code/inbox/runs': typeof CodeInboxRunsRouteWithChildren @@ -315,6 +336,7 @@ export interface FileRoutesByFullPath { '/website/$channelId/': typeof WebsiteChannelIdIndexRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute + '/code/inbox/not-actionable/$reportId': typeof CodeInboxNotActionableReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute '/code/inbox/runs/$reportId': typeof CodeInboxRunsReportIdRoute @@ -322,6 +344,7 @@ export interface FileRoutesByFullPath { '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute '/code/inbox/dismissed/': typeof CodeInboxDismissedIndexRoute + '/code/inbox/not-actionable/': typeof CodeInboxNotActionableIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -353,6 +376,7 @@ export interface FileRoutesByTo { '/code/inbox': typeof CodeInboxIndexRoute '/website/$channelId': typeof WebsiteChannelIdIndexRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute + '/code/inbox/not-actionable/$reportId': typeof CodeInboxNotActionableReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute '/code/inbox/runs/$reportId': typeof CodeInboxRunsReportIdRoute @@ -360,6 +384,7 @@ export interface FileRoutesByTo { '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute '/code/inbox/dismissed': typeof CodeInboxDismissedIndexRoute + '/code/inbox/not-actionable': typeof CodeInboxNotActionableIndexRoute '/code/inbox/pulls': typeof CodeInboxPullsIndexRoute '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute @@ -389,6 +414,7 @@ export interface FileRoutesById { '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/dismissed': typeof CodeInboxDismissedRouteWithChildren + '/code/inbox/not-actionable': typeof CodeInboxNotActionableRouteWithChildren '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren '/code/inbox/reports': typeof CodeInboxReportsRouteWithChildren '/code/inbox/runs': typeof CodeInboxRunsRouteWithChildren @@ -400,6 +426,7 @@ export interface FileRoutesById { '/website/$channelId/': typeof WebsiteChannelIdIndexRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute + '/code/inbox/not-actionable/$reportId': typeof CodeInboxNotActionableReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute '/code/inbox/runs/$reportId': typeof CodeInboxRunsReportIdRoute @@ -407,6 +434,7 @@ export interface FileRoutesById { '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute '/code/inbox/dismissed/': typeof CodeInboxDismissedIndexRoute + '/code/inbox/not-actionable/': typeof CodeInboxNotActionableIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -437,6 +465,7 @@ export interface FileRouteTypes { | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/dismissed' + | '/code/inbox/not-actionable' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' @@ -448,6 +477,7 @@ export interface FileRouteTypes { | '/website/$channelId/' | '/code/agents/scouts/$skillName' | '/code/inbox/dismissed/$reportId' + | '/code/inbox/not-actionable/$reportId' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' | '/code/inbox/runs/$reportId' @@ -455,6 +485,7 @@ export interface FileRouteTypes { | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' | '/code/inbox/dismissed/' + | '/code/inbox/not-actionable/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -486,6 +517,7 @@ export interface FileRouteTypes { | '/code/inbox' | '/website/$channelId' | '/code/inbox/dismissed/$reportId' + | '/code/inbox/not-actionable/$reportId' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' | '/code/inbox/runs/$reportId' @@ -493,6 +525,7 @@ export interface FileRouteTypes { | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' | '/code/inbox/dismissed' + | '/code/inbox/not-actionable' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' @@ -521,6 +554,7 @@ export interface FileRouteTypes { | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/dismissed' + | '/code/inbox/not-actionable' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' @@ -532,6 +566,7 @@ export interface FileRouteTypes { | '/website/$channelId/' | '/code/agents/scouts/$skillName' | '/code/inbox/dismissed/$reportId' + | '/code/inbox/not-actionable/$reportId' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' | '/code/inbox/runs/$reportId' @@ -539,6 +574,7 @@ export interface FileRouteTypes { | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' | '/code/inbox/dismissed/' + | '/code/inbox/not-actionable/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -761,6 +797,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsRouteImport parentRoute: typeof CodeInboxRoute } + '/code/inbox/not-actionable': { + id: '/code/inbox/not-actionable' + path: '/not-actionable' + fullPath: '/code/inbox/not-actionable' + preLoaderRoute: typeof CodeInboxNotActionableRouteImport + parentRoute: typeof CodeInboxRoute + } '/code/inbox/dismissed': { id: '/code/inbox/dismissed' path: '/dismissed' @@ -803,6 +846,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsIndexRouteImport parentRoute: typeof CodeInboxPullsRoute } + '/code/inbox/not-actionable/': { + id: '/code/inbox/not-actionable/' + path: '/' + fullPath: '/code/inbox/not-actionable/' + preLoaderRoute: typeof CodeInboxNotActionableIndexRouteImport + parentRoute: typeof CodeInboxNotActionableRoute + } '/code/inbox/dismissed/': { id: '/code/inbox/dismissed/' path: '/' @@ -852,6 +902,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsReportIdRouteImport parentRoute: typeof CodeInboxPullsRoute } + '/code/inbox/not-actionable/$reportId': { + id: '/code/inbox/not-actionable/$reportId' + path: '/$reportId' + fullPath: '/code/inbox/not-actionable/$reportId' + preLoaderRoute: typeof CodeInboxNotActionableReportIdRouteImport + parentRoute: typeof CodeInboxNotActionableRoute + } '/code/inbox/dismissed/$reportId': { id: '/code/inbox/dismissed/$reportId' path: '/$reportId' @@ -960,6 +1017,22 @@ const CodeInboxDismissedRouteChildren: CodeInboxDismissedRouteChildren = { const CodeInboxDismissedRouteWithChildren = CodeInboxDismissedRoute._addFileChildren(CodeInboxDismissedRouteChildren) +interface CodeInboxNotActionableRouteChildren { + CodeInboxNotActionableReportIdRoute: typeof CodeInboxNotActionableReportIdRoute + CodeInboxNotActionableIndexRoute: typeof CodeInboxNotActionableIndexRoute +} + +const CodeInboxNotActionableRouteChildren: CodeInboxNotActionableRouteChildren = + { + CodeInboxNotActionableReportIdRoute: CodeInboxNotActionableReportIdRoute, + CodeInboxNotActionableIndexRoute: CodeInboxNotActionableIndexRoute, + } + +const CodeInboxNotActionableRouteWithChildren = + CodeInboxNotActionableRoute._addFileChildren( + CodeInboxNotActionableRouteChildren, + ) + interface CodeInboxPullsRouteChildren { CodeInboxPullsReportIdRoute: typeof CodeInboxPullsReportIdRoute CodeInboxPullsIndexRoute: typeof CodeInboxPullsIndexRoute @@ -1004,6 +1077,7 @@ const CodeInboxRunsRouteWithChildren = CodeInboxRunsRoute._addFileChildren( interface CodeInboxRouteChildren { CodeInboxAgentsRoute: typeof CodeInboxAgentsRoute CodeInboxDismissedRoute: typeof CodeInboxDismissedRouteWithChildren + CodeInboxNotActionableRoute: typeof CodeInboxNotActionableRouteWithChildren CodeInboxPullsRoute: typeof CodeInboxPullsRouteWithChildren CodeInboxReportsRoute: typeof CodeInboxReportsRouteWithChildren CodeInboxRunsRoute: typeof CodeInboxRunsRouteWithChildren @@ -1013,6 +1087,7 @@ interface CodeInboxRouteChildren { const CodeInboxRouteChildren: CodeInboxRouteChildren = { CodeInboxAgentsRoute: CodeInboxAgentsRoute, CodeInboxDismissedRoute: CodeInboxDismissedRouteWithChildren, + CodeInboxNotActionableRoute: CodeInboxNotActionableRouteWithChildren, CodeInboxPullsRoute: CodeInboxPullsRouteWithChildren, CodeInboxReportsRoute: CodeInboxReportsRouteWithChildren, CodeInboxRunsRoute: CodeInboxRunsRouteWithChildren, diff --git a/packages/ui/src/router/routes/code/inbox/not-actionable.$reportId.tsx b/packages/ui/src/router/routes/code/inbox/not-actionable.$reportId.tsx new file mode 100644 index 0000000000..9e01feb0fb --- /dev/null +++ b/packages/ui/src/router/routes/code/inbox/not-actionable.$reportId.tsx @@ -0,0 +1,24 @@ +import type { SignalReport } from "@posthog/shared/types"; +import { ReportDetail } from "@posthog/ui/features/inbox/components/ReportDetail"; +import { getCachedInboxReportDetail } from "@posthog/ui/features/inbox/inboxQueries"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/inbox/not-actionable/$reportId")({ + component: NotActionableReportDetailRoute, + pendingComponent: () => null, + loader: ({ params }): SignalReport | null => + getCachedInboxReportDetail(params.reportId) ?? null, +}); + +function NotActionableReportDetailRoute() { + const { reportId } = Route.useParams(); + const cachedReport = Route.useLoaderData(); + return ( + + ); +} diff --git a/packages/ui/src/router/routes/code/inbox/not-actionable.index.tsx b/packages/ui/src/router/routes/code/inbox/not-actionable.index.tsx new file mode 100644 index 0000000000..c0d3630bcc --- /dev/null +++ b/packages/ui/src/router/routes/code/inbox/not-actionable.index.tsx @@ -0,0 +1,6 @@ +import { NotActionableTab } from "@posthog/ui/features/inbox/components/NotActionableTab"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/inbox/not-actionable/")({ + component: NotActionableTab, +}); diff --git a/packages/ui/src/router/routes/code/inbox/not-actionable.tsx b/packages/ui/src/router/routes/code/inbox/not-actionable.tsx new file mode 100644 index 0000000000..69c40f50d1 --- /dev/null +++ b/packages/ui/src/router/routes/code/inbox/not-actionable.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/inbox/not-actionable")({ + component: Outlet, +}); From 70ebd359b5599e5cf6fcffd5e186a186b7f2b387 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 25 Jun 2026 20:28:02 +0100 Subject: [PATCH 2/3] Guard isNotActionableReport against in-flight and failed runs isReportTabReport checks isAgentRunReport / failed before the not-actionable check, but isNotActionableReport had no such guard. A queued/live not_actionable report would therefore classify as a run in one predicate and as not-actionable in the other, double-listing in the Runs queued/live sections and the Not actionable tab. Mirror the same gates so the two predicates agree (per Greptile review). The Runs "Recently finished" overlap for ready reports is separate and intentional. Generated-By: PostHog Code Task-Id: 3a7dec81-6f1d-425a-a7cb-f468c36b3ec7 --- .../core/src/inbox/reportMembership.test.ts | 27 +++++++++++++++++++ packages/core/src/inbox/reportMembership.ts | 6 +++++ 2 files changed, 33 insertions(+) diff --git a/packages/core/src/inbox/reportMembership.test.ts b/packages/core/src/inbox/reportMembership.test.ts index a0e6334d44..bb9f0e4e71 100644 --- a/packages/core/src/inbox/reportMembership.test.ts +++ b/packages/core/src/inbox/reportMembership.test.ts @@ -104,6 +104,33 @@ describe("isNotActionableReport", () => { ), ).toBe(false); }); + + it.each(["potential", "candidate", "in_progress", "pending_input"] as const)( + "excludes in-flight %s runs (they stay in the Runs tab)", + (status) => { + expect( + isNotActionableReport( + fakeReport({ status, actionability: "not_actionable" }), + ), + ).toBe(false); + }, + ); + + it("excludes failed reports (they stay in the Runs tab)", () => { + expect( + isNotActionableReport( + fakeReport({ status: "failed", actionability: "not_actionable" }), + ), + ).toBe(false); + }); + + it("matches a settled (ready) not_actionable report", () => { + expect( + isNotActionableReport( + fakeReport({ status: "ready", actionability: "not_actionable" }), + ), + ).toBe(true); + }); }); describe("isStaffOnlyInboxTab", () => { diff --git a/packages/core/src/inbox/reportMembership.ts b/packages/core/src/inbox/reportMembership.ts index 1f56189a35..78d7f72284 100644 --- a/packages/core/src/inbox/reportMembership.ts +++ b/packages/core/src/inbox/reportMembership.ts @@ -274,7 +274,13 @@ export function isReportTabReport(report: SignalReport): boolean { */ export function isNotActionableReport(report: SignalReport): boolean { if (isExcludedFromInbox(report)) return false; + if (report.status === "failed") return false; // failed runs live in the Runs tab only if (report.implementation_pr_url) return false; + // In-flight (queued/live) runs belong to the Runs tab until they settle, even + // if a not_actionable judgment is already attached. Mirror the gate order in + // `isReportTabReport` so the two predicates classify a report the same way and + // it can't show in both the Runs and Not actionable tabs at once. + if (isAgentRunReport(report)) return false; return report.actionability === "not_actionable"; } From 4d4b2a8893d3353348355c9fba7fb6e06e18e471 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Thu, 25 Jun 2026 20:49:29 +0100 Subject: [PATCH 3/3] Redirect resolved reports to Archive and hide their Restore action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding `resolved` to INBOX_EXCLUDED_STATUSES left two gaps for resolved (merged-PR) reports: - InboxReportDetailGate's stale-URL redirect only checked `suppressed`, so a resolved report opened at a /pulls, /reports, or /runs URL rendered full triage actions instead of redirecting to Archive. Switch to the isDismissedReport predicate (suppressed OR resolved) so both terminal states redirect symmetrically. - DismissedReportDetail always rendered Restore; resolved reports are terminal, so gate it with isResolvedReport — matching the list-card behavior. Per Greptile review. Generated-By: PostHog Code Task-Id: 3a7dec81-6f1d-425a-a7cb-f468c36b3ec7 --- .../components/DismissedReportDetail.tsx | 17 ++++++++------ .../components/InboxReportDetailGate.tsx | 22 ++++++++++--------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx b/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx index a5c9d2aa78..eedf0478f3 100644 --- a/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx +++ b/packages/ui/src/features/inbox/components/DismissedReportDetail.tsx @@ -4,6 +4,7 @@ import { FileTextIcon, MagnifyingGlassIcon, } from "@phosphor-icons/react"; +import { isResolvedReport } from "@posthog/core/inbox/reportMembership"; import { Button } from "@posthog/quill"; import type { SignalReport } from "@posthog/shared/types"; import { InboxDetailFrame } from "@posthog/ui/features/inbox/components/InboxDetailFrame"; @@ -19,14 +20,15 @@ interface DismissedReportDetailProps { } /** - * Detail view for an archived (suppressed) report. Read-only re-read of what the - * report was — summary + evidence — with a single Restore action. No triage - * affordances (archive, discuss, create PR, reviewers): the report is out of the - * pipeline until it's restored. + * Detail view for an archived report. Read-only re-read of what the report was — + * summary + evidence. Suppressed reports get a single Restore action; resolved + * reports (their PR merged) are terminal and shown for reference only, with no + * Restore. No triage affordances (archive, discuss, create PR, reviewers): the + * report is out of the pipeline. * * The gate keeps reports on the route that matches their status: a no-longer- - * suppressed report opened here (stale URL or restored elsewhere) is redirected - * to its current home, so this content only ever renders a suppressed report. + * archived report opened here (stale URL or restored elsewhere) is redirected to + * its current home, so this content only ever renders an archived report. */ export function DismissedReportDetail({ reportId, @@ -55,7 +57,8 @@ function DismissedReportDetailContent({ report }: { report: SignalReport }) { showDismiss={false} primaryAction={ <> - + {/* Resolved reports are terminal (PR merged) — no Restore. */} + {!isResolvedReport(report) && }