Skip to content

Commit d71f752

Browse files
committed
feat(scouts): link scout findings to their inbox report
Surfaces the reverse of the report -> signals link on the Scout page: each finding now shows a chip linking to the inbox report (if any) its signal grouped into, replacing the "report assignment isn't traceable here yet" note. Backed by the new GET signals/scout/runs/<run_id>/emissions/reports endpoint (PostHog/posthog#63817). - api-client: ScoutEmissionReportLink / LinkedSignalReport types + listScoutEmissionReports. - useScoutEmissionReports: per-run reverse-lookup query, loaded alongside useScoutRunEmissions. Best effort — a failure is non-fatal, the cards just render without the chip. - ScoutLinkedReportChip: footer chip that opens the report in the inbox. - useOpenInboxReport: extracted from useInboxDeepLink (fetch by id, seed cache, reset filters, navigate to the right tab) and reused by the chip so both paths land on the correct surface.
1 parent e3c320b commit d71f752

9 files changed

Lines changed: 230 additions & 73 deletions

File tree

packages/api-client/src/posthog-client.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
SignalReportArtefact,
2929
SignalReportArtefactsResponse,
3030
SignalReportSignalsResponse,
31+
SignalReportStatus,
3132
SignalReportsQueryParams,
3233
SignalReportsResponse,
3334
SignalReportTask,
@@ -268,6 +269,24 @@ export interface ScoutEmission {
268269
emitted_at: string;
269270
}
270271

272+
/** Minimal inbox report projection paired with a scout finding by the reverse lookup. */
273+
export interface LinkedSignalReport {
274+
id: string;
275+
title: string | null;
276+
status: SignalReportStatus;
277+
}
278+
279+
/**
280+
* One scout finding paired with the inbox report (if any) its signal grouped into.
281+
* `report` is null when the finding hasn't grouped into a report yet, was
282+
* de-duplicated away, or its signal was deleted – the link is best effort.
283+
*/
284+
export interface ScoutEmissionReportLink {
285+
finding_id: string;
286+
source_id: string;
287+
report: LinkedSignalReport | null;
288+
}
289+
271290
export interface ScoutScratchpadEntry {
272291
key: string;
273292
content: string;
@@ -1456,6 +1475,21 @@ export class PostHogAPIClient {
14561475
return Array.isArray(data) ? data : (data.results ?? []);
14571476
}
14581477

1478+
/**
1479+
* Best-effort reverse lookup: for each finding a run emitted, the inbox report
1480+
* (if any) its underlying signal grouped into. Pairs with the report's evidence
1481+
* list, which links the other direction.
1482+
*/
1483+
async listScoutEmissionReports(
1484+
projectId: number,
1485+
runId: string,
1486+
): Promise<ScoutEmissionReportLink[]> {
1487+
const data = await this.scoutGet<
1488+
{ results: ScoutEmissionReportLink[] } | ScoutEmissionReportLink[]
1489+
>(projectId, `runs/${runId}/emissions/reports/`);
1490+
return Array.isArray(data) ? data : (data.results ?? []);
1491+
}
1492+
14591493
async searchScoutScratchpad(
14601494
projectId: number,
14611495
params?: { text?: string; limit?: number },

packages/shared/src/analytics-events.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -657,6 +657,7 @@ export type ScoutActionType =
657657
| "open_skill_in_posthog"
658658
| "open_helper_skill"
659659
| "copy_finding_link"
660+
| "open_linked_report"
660661
| "show_more_emitted_runs"
661662
| "filter_runs"
662663
| "toggle_hide_disabled"
@@ -715,6 +716,8 @@ export interface ScoutActionProperties {
715716
filter_match_count?: number;
716717
helper_skill?: string;
717718
hide_disabled?: boolean;
719+
/** Status of the linked inbox report, for `open_linked_report`. */
720+
report_status?: string;
718721
}
719722

720723
export interface SignalSourceConnectedProperties {

packages/ui/src/features/inbox/hooks/useInboxDeepLink.ts

Lines changed: 9 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -1,49 +1,30 @@
1-
import { seedInboxReportDetailCache } from "@posthog/core/inbox/inboxQuery";
21
import { useHostTRPC } from "@posthog/host-router/react";
32
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
43
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
5-
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
6-
import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports";
7-
import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore";
8-
import {
9-
navigateToInboxPullRequestDetail,
10-
navigateToInboxReportDetail,
11-
} from "@posthog/ui/router/navigationBridge";
12-
import { logger } from "@posthog/ui/shell/logger";
13-
import { useQuery, useQueryClient } from "@tanstack/react-query";
4+
import { useOpenInboxReport } from "@posthog/ui/features/inbox/hooks/useOpenInboxReport";
5+
import { useQuery } from "@tanstack/react-query";
146
import { useSubscription } from "@trpc/tanstack-react-query";
15-
import { useCallback, useEffect } from "react";
16-
import { toast } from "sonner";
17-
18-
const log = logger.scope("inbox-deep-link");
7+
import { useEffect } from "react";
198

209
/**
2110
* Hook that subscribes to inbox report deep link events (`<scheme>://inbox/{reportId}`,
2211
* e.g. `posthog-code://…` in production and `posthog-code-dev://…` in local dev)
2312
* and opens the report in the inbox view.
2413
*
25-
* Behavior on link arrival:
26-
* 1. Fetch the report by id directly, bypassing the paginated list, and seed
27-
* the TanStack Query cache so the detail pane fallback reuses it.
28-
* - On 404/403 (wrong team / deleted / suppressed): toast "Report not found
29-
* in the current team" and leave the current view untouched.
30-
* - On transient failure: toast a generic error and leave state untouched.
31-
* 2. Only on success: reset inbox-local filters (so the report isn't hidden)
32-
* and navigate directly to the report's detail view. The tab is picked from
33-
* the report itself – Pulls if it has an implementation PR, otherwise
34-
* Reports – so the user lands on the right surface regardless of which tab
35-
* was last selected. Runs reports also surface in the report detail view,
36-
* where the run logs are visible.
14+
* The actual open – fetch by id, seed the detail cache, reset filters, and
15+
* navigate to the right tab (Pulls if it has an implementation PR, otherwise
16+
* Reports) – lives in {@link useOpenInboxReport}, shared with other in-app
17+
* surfaces that link to a report by id. On 404/403 (wrong team / deleted /
18+
* suppressed) it toasts and leaves the current view untouched.
3719
*/
3820
export function useInboxDeepLink() {
3921
const trpcReact = useHostTRPC();
40-
const queryClient = useQueryClient();
4122
const client = useOptionalAuthenticatedClient();
4223
const isAuthenticated = useAuthStateValue(
4324
(s) => s.status === "authenticated",
4425
);
4526

46-
const resetFilters = useInboxSignalsFilterStore((s) => s.resetFilters);
27+
const openReport = useOpenInboxReport();
4728

4829
const pendingDeepLink = useQuery(
4930
trpcReact.deepLink.getPendingReportLink.queryOptions(undefined, {
@@ -55,44 +36,6 @@ export function useInboxDeepLink() {
5536
}),
5637
);
5738

58-
const openReport = useCallback(
59-
async (reportId: string) => {
60-
if (!client) {
61-
log.warn("Ignoring inbox deep link – not authenticated");
62-
return;
63-
}
64-
65-
log.info(`Opening report from deep link: ${reportId}`);
66-
67-
try {
68-
const report = await queryClient.fetchQuery({
69-
queryKey: reportKeys.detail(reportId),
70-
queryFn: () => client.getSignalReport(reportId),
71-
meta: AUTH_SCOPED_QUERY_META,
72-
});
73-
74-
if (!report) {
75-
log.warn(`Report not found or not accessible: ${reportId}`);
76-
toast.error("Report not found in the current team");
77-
return;
78-
}
79-
80-
resetFilters();
81-
seedInboxReportDetailCache(queryClient, report);
82-
if (report.implementation_pr_url) {
83-
navigateToInboxPullRequestDetail(report.id);
84-
} else {
85-
navigateToInboxReportDetail(report.id);
86-
}
87-
log.info(`Successfully opened report from deep link: ${report.id}`);
88-
} catch (error) {
89-
log.error("Unexpected error opening report from deep link:", error);
90-
toast.error("Failed to open report");
91-
}
92-
},
93-
[client, queryClient, resetFilters],
94-
);
95-
9639
useEffect(() => {
9740
if (pendingDeepLink.data?.reportId) {
9841
void openReport(pendingDeepLink.data.reportId);
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { seedInboxReportDetailCache } from "@posthog/core/inbox/inboxQuery";
2+
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
3+
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
4+
import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports";
5+
import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore";
6+
import {
7+
navigateToInboxPullRequestDetail,
8+
navigateToInboxReportDetail,
9+
} from "@posthog/ui/router/navigationBridge";
10+
import { logger } from "@posthog/ui/shell/logger";
11+
import { useQueryClient } from "@tanstack/react-query";
12+
import { useCallback } from "react";
13+
import { toast } from "sonner";
14+
15+
const log = logger.scope("open-inbox-report");
16+
17+
/**
18+
* Returns a callback that opens an inbox report by id: fetch it directly
19+
* (bypassing the paginated list), seed the detail cache, reset inbox-local
20+
* filters so it isn't hidden, then navigate to the right tab – Pulls when it
21+
* has an implementation PR, otherwise Reports.
22+
*
23+
* Shared by the deep-link handler ({@link useInboxDeepLink}) and any in-app
24+
* surface that links to a report it only knows by id (e.g. the scout finding
25+
* → linked report chip). On 404/403 (wrong team / deleted / suppressed) it
26+
* toasts and leaves the current view untouched.
27+
*/
28+
export function useOpenInboxReport() {
29+
const queryClient = useQueryClient();
30+
const client = useOptionalAuthenticatedClient();
31+
const resetFilters = useInboxSignalsFilterStore((s) => s.resetFilters);
32+
33+
return useCallback(
34+
async (reportId: string) => {
35+
if (!client) {
36+
log.warn("Ignoring open-report request – not authenticated");
37+
return;
38+
}
39+
40+
log.info(`Opening report: ${reportId}`);
41+
42+
try {
43+
const report = await queryClient.fetchQuery({
44+
queryKey: reportKeys.detail(reportId),
45+
queryFn: () => client.getSignalReport(reportId),
46+
meta: AUTH_SCOPED_QUERY_META,
47+
});
48+
49+
if (!report) {
50+
log.warn(`Report not found or not accessible: ${reportId}`);
51+
toast.error("Report not found in the current team");
52+
return;
53+
}
54+
55+
resetFilters();
56+
seedInboxReportDetailCache(queryClient, report);
57+
if (report.implementation_pr_url) {
58+
navigateToInboxPullRequestDetail(report.id);
59+
} else {
60+
navigateToInboxReportDetail(report.id);
61+
}
62+
log.info(`Successfully opened report: ${report.id}`);
63+
} catch (error) {
64+
log.error("Unexpected error opening report:", error);
65+
toast.error("Failed to open report");
66+
}
67+
},
68+
[client, queryClient, resetFilters],
69+
);
70+
}

packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx

Lines changed: 25 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,23 @@
11
import { CaretRightIcon, CompassIcon } from "@phosphor-icons/react";
2-
import type { ScoutEmission } from "@posthog/api-client/posthog-client";
2+
import type {
3+
LinkedSignalReport,
4+
ScoutEmission,
5+
} from "@posthog/api-client/posthog-client";
36
import { ANALYTICS_EVENTS } from "@posthog/shared";
47
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
58
import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp";
69
import { track } from "@posthog/ui/shell/analytics";
710
import { Box, Flex, Text } from "@radix-ui/themes";
811
import { type ReactNode, useEffect, useRef, useState } from "react";
912
import { SeverityBadge } from "./ScoutBadges";
13+
import { ScoutLinkedReportChip } from "./ScoutLinkedReportChip";
1014

1115
export function ScoutEmissionCard({
1216
emission,
1317
skillName,
1418
actions,
1519
footerEnd,
20+
linkedReport,
1621
defaultExpanded = false,
1722
highlighted = false,
1823
}: {
@@ -23,6 +28,12 @@ export function ScoutEmissionCard({
2328
actions?: ReactNode;
2429
/** Replaces the default pipeline note at the footer's right edge. */
2530
footerEnd?: ReactNode;
31+
/**
32+
* The inbox report this finding's signal grouped into, when resolved by the
33+
* reverse lookup. Renders a chip linking to it; absent/undefined falls back
34+
* to the generic pipeline note.
35+
*/
36+
linkedReport?: LinkedSignalReport | null;
2637
defaultExpanded?: boolean;
2738
/** True when a shared finding link targets this card – scrolls it into view. */
2839
highlighted?: boolean;
@@ -89,14 +100,21 @@ export function ScoutEmissionCard({
89100
className="border-t border-t-(--gray-5) text-[11px] text-gray-10"
90101
>
91102
<Text className="font-mono text-[11px]">{emission.finding_id}</Text>
103+
{linkedReport ? (
104+
<ScoutLinkedReportChip
105+
report={linkedReport}
106+
skillName={skillName}
107+
/>
108+
) : null}
92109
{actions}
93110
<span className="flex-1" />
94-
{footerEnd ?? (
95-
<Text className="text-[11px] text-gray-9">
96-
Sent to the signals pipeline – report assignment isn&apos;t
97-
traceable here yet
98-
</Text>
99-
)}
111+
{footerEnd ??
112+
(linkedReport ? null : (
113+
<Text className="text-[11px] text-gray-9">
114+
Sent to the signals pipeline – report assignment isn&apos;t
115+
traceable here yet
116+
</Text>
117+
))}
100118
</Flex>
101119
) : null}
102120
</Box>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { TrayIcon } from "@phosphor-icons/react";
2+
import type { LinkedSignalReport } from "@posthog/api-client/posthog-client";
3+
import { ANALYTICS_EVENTS } from "@posthog/shared";
4+
import { useOpenInboxReport } from "@posthog/ui/features/inbox/hooks/useOpenInboxReport";
5+
import { track } from "@posthog/ui/shell/analytics";
6+
import { Text } from "@radix-ui/themes";
7+
import { useState } from "react";
8+
9+
/**
10+
* Footer chip on a scout emission card linking to the inbox report this finding
11+
* grouped into. Best effort: only rendered when the reverse lookup resolved a
12+
* report. Clicking opens the report in the inbox via the shared open-report
13+
* flow (fetch by id, seed cache, navigate to the right tab).
14+
*/
15+
export function ScoutLinkedReportChip({
16+
report,
17+
skillName,
18+
}: {
19+
report: LinkedSignalReport;
20+
/** The emitting scout, attached to analytics when known. */
21+
skillName?: string;
22+
}) {
23+
const openReport = useOpenInboxReport();
24+
const [opening, setOpening] = useState(false);
25+
26+
return (
27+
<button
28+
type="button"
29+
disabled={opening}
30+
onClick={async () => {
31+
track(ANALYTICS_EVENTS.SCOUT_ACTION, {
32+
action_type: "open_linked_report",
33+
surface: "scout_detail",
34+
skill_name: skillName,
35+
report_status: report.status,
36+
});
37+
setOpening(true);
38+
try {
39+
await openReport(report.id);
40+
} finally {
41+
setOpening(false);
42+
}
43+
}}
44+
className="flex max-w-[16rem] items-center gap-1 rounded-full bg-(--iris-3) px-2 py-0.5 text-(--iris-11) transition-colors hover:bg-(--iris-4) disabled:opacity-60"
45+
title={report.title ?? "View linked report"}
46+
>
47+
<TrayIcon size={12} className="shrink-0" />
48+
<Text className="truncate text-[11px]">
49+
{report.title ? `In report: ${report.title}` : "View linked report"}
50+
</Text>
51+
</button>
52+
);
53+
}

packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { track } from "@posthog/ui/shell/analytics";
44
import { getPostHogUrl } from "@posthog/ui/utils/urls";
55
import { Box, Flex, Text } from "@radix-ui/themes";
66
import { useState } from "react";
7+
import { useScoutEmissionReports } from "../hooks/useScoutEmissionReports";
78
import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions";
89
import { ScoutEmissionCard } from "./ScoutEmissionCard";
910
import { ScoutFindingDiscussButton } from "./ScoutFindingDiscussButton";
@@ -101,6 +102,14 @@ function RunEmissions({
101102
isLoading,
102103
isError,
103104
} = useScoutRunEmissions(run.run_id);
105+
// Best-effort reverse lookup of which inbox report each finding grouped into.
106+
// A failure here is non-fatal: the cards still render, just without the chip.
107+
const { data: emissionReports } = useScoutEmissionReports(run.run_id);
108+
const reportBySourceId = new Map(
109+
(emissionReports ?? [])
110+
.filter((link) => link.report)
111+
.map((link) => [link.source_id, link.report]),
112+
);
104113
const taskRunUrl = run.task_url ? getPostHogUrl(run.task_url) : null;
105114

106115
if (isLoading) {
@@ -137,6 +146,7 @@ function RunEmissions({
137146
key={emission.id}
138147
emission={emission}
139148
skillName={run.skill_name}
149+
linkedReport={reportBySourceId.get(emission.source_id)}
140150
defaultExpanded={emission.id === highlightFindingId}
141151
highlighted={emission.id === highlightFindingId}
142152
actions={

0 commit comments

Comments
 (0)