Skip to content
Merged
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
34 changes: 34 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
SignalReportArtefact,
SignalReportArtefactsResponse,
SignalReportSignalsResponse,
SignalReportStatus,
SignalReportsQueryParams,
SignalReportsResponse,
SignalReportTask,
Expand Down Expand Up @@ -268,6 +269,24 @@ export interface ScoutEmission {
emitted_at: string;
}

/** Minimal inbox report projection paired with a scout finding by the reverse lookup. */
export interface LinkedSignalReport {
id: string;
title: string | null;
status: SignalReportStatus;
}

/**
* One scout finding paired with the inbox report (if any) its signal grouped into.
* `report` is null when the finding hasn't grouped into a report yet, was
* de-duplicated away, or its signal was deleted – the link is best effort.
*/
export interface ScoutEmissionReportLink {
finding_id: string;
source_id: string;
report: LinkedSignalReport | null;
}

export interface ScoutScratchpadEntry {
key: string;
content: string;
Expand Down Expand Up @@ -1456,6 +1475,21 @@ export class PostHogAPIClient {
return Array.isArray(data) ? data : (data.results ?? []);
}

/**
* Best-effort reverse lookup: for each finding a run emitted, the inbox report
* (if any) its underlying signal grouped into. Pairs with the report's evidence
* list, which links the other direction.
*/
async listScoutEmissionReports(
projectId: number,
runId: string,
): Promise<ScoutEmissionReportLink[]> {
const data = await this.scoutGet<
{ results: ScoutEmissionReportLink[] } | ScoutEmissionReportLink[]
>(projectId, `runs/${runId}/emissions/reports/`);
Comment on lines +1487 to +1489

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Swallow 404s for the optional scout lookup

When this client is deployed against a backend before the new emissions/reports/ action exists, scoutGet uses the shared fetcher, which throws on any non-2xx in packages/api-client/src/fetcher.ts, so this call rejects with a 404 instead of producing an empty link list. Since useScoutEmissionReports mounts one query per visible emitted run, opening a scout page on that backend will generate failing API requests rather than the intended quiet fallback; catch 404 here and return [] if the endpoint is optional.

Useful? React with 👍 / 👎.

return Array.isArray(data) ? data : (data.results ?? []);
}

async searchScoutScratchpad(
projectId: number,
params?: { text?: string; limit?: number },
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -657,6 +657,7 @@ export type ScoutActionType =
| "open_skill_in_posthog"
| "open_helper_skill"
| "copy_finding_link"
| "open_linked_report"
| "show_more_emitted_runs"
| "filter_runs"
| "toggle_hide_disabled"
Expand Down Expand Up @@ -715,6 +716,8 @@ export interface ScoutActionProperties {
filter_match_count?: number;
helper_skill?: string;
hide_disabled?: boolean;
/** Status of the linked inbox report, for `open_linked_report`. */
report_status?: string;
}

export interface SignalSourceConnectedProperties {
Expand Down
75 changes: 9 additions & 66 deletions packages/ui/src/features/inbox/hooks/useInboxDeepLink.ts
Original file line number Diff line number Diff line change
@@ -1,49 +1,30 @@
import { seedInboxReportDetailCache } from "@posthog/core/inbox/inboxQuery";
import { useHostTRPC } from "@posthog/host-router/react";
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports";
import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore";
import {
navigateToInboxPullRequestDetail,
navigateToInboxReportDetail,
} from "@posthog/ui/router/navigationBridge";
import { logger } from "@posthog/ui/shell/logger";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useOpenInboxReport } from "@posthog/ui/features/inbox/hooks/useOpenInboxReport";
import { useQuery } from "@tanstack/react-query";
import { useSubscription } from "@trpc/tanstack-react-query";
import { useCallback, useEffect } from "react";
import { toast } from "sonner";

const log = logger.scope("inbox-deep-link");
import { useEffect } from "react";

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

const resetFilters = useInboxSignalsFilterStore((s) => s.resetFilters);
const openReport = useOpenInboxReport();

const pendingDeepLink = useQuery(
trpcReact.deepLink.getPendingReportLink.queryOptions(undefined, {
Expand All @@ -55,44 +36,6 @@ export function useInboxDeepLink() {
}),
);

const openReport = useCallback(
async (reportId: string) => {
if (!client) {
log.warn("Ignoring inbox deep link – not authenticated");
return;
}

log.info(`Opening report from deep link: ${reportId}`);

try {
const report = await queryClient.fetchQuery({
queryKey: reportKeys.detail(reportId),
queryFn: () => client.getSignalReport(reportId),
meta: AUTH_SCOPED_QUERY_META,
});

if (!report) {
log.warn(`Report not found or not accessible: ${reportId}`);
toast.error("Report not found in the current team");
return;
}

resetFilters();
seedInboxReportDetailCache(queryClient, report);
if (report.implementation_pr_url) {
navigateToInboxPullRequestDetail(report.id);
} else {
navigateToInboxReportDetail(report.id);
}
log.info(`Successfully opened report from deep link: ${report.id}`);
} catch (error) {
log.error("Unexpected error opening report from deep link:", error);
toast.error("Failed to open report");
}
},
[client, queryClient, resetFilters],
);

useEffect(() => {
if (pendingDeepLink.data?.reportId) {
void openReport(pendingDeepLink.data.reportId);
Expand Down
70 changes: 70 additions & 0 deletions packages/ui/src/features/inbox/hooks/useOpenInboxReport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { seedInboxReportDetailCache } from "@posthog/core/inbox/inboxQuery";
import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient";
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
import { reportKeys } from "@posthog/ui/features/inbox/hooks/useInboxReports";
import { useInboxSignalsFilterStore } from "@posthog/ui/features/inbox/stores/inboxSignalsFilterStore";
import {
navigateToInboxPullRequestDetail,
navigateToInboxReportDetail,
} from "@posthog/ui/router/navigationBridge";
import { logger } from "@posthog/ui/shell/logger";
import { useQueryClient } from "@tanstack/react-query";
import { useCallback } from "react";
import { toast } from "sonner";

const log = logger.scope("open-inbox-report");

/**
* Returns a callback that opens an inbox report by id: fetch it directly
* (bypassing the paginated list), seed the detail cache, reset inbox-local
* filters so it isn't hidden, then navigate to the right tab – Pulls when it
* has an implementation PR, otherwise Reports.
*
* Shared by the deep-link handler ({@link useInboxDeepLink}) and any in-app
* surface that links to a report it only knows by id (e.g. the scout finding
* → linked report chip). On 404/403 (wrong team / deleted / suppressed) it
* toasts and leaves the current view untouched.
*/
export function useOpenInboxReport() {
const queryClient = useQueryClient();
const client = useOptionalAuthenticatedClient();
const resetFilters = useInboxSignalsFilterStore((s) => s.resetFilters);

return useCallback(
async (reportId: string) => {
if (!client) {
log.warn("Ignoring open-report request – not authenticated");
return;
}

log.info(`Opening report: ${reportId}`);

try {
const report = await queryClient.fetchQuery({
queryKey: reportKeys.detail(reportId),
queryFn: () => client.getSignalReport(reportId),
meta: AUTH_SCOPED_QUERY_META,
});

if (!report) {
log.warn(`Report not found or not accessible: ${reportId}`);
toast.error("Report not found in the current team");
return;
}

resetFilters();
seedInboxReportDetailCache(queryClient, report);
if (report.implementation_pr_url) {
navigateToInboxPullRequestDetail(report.id);
} else {
navigateToInboxReportDetail(report.id);
Comment on lines +57 to +60

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Route run reports to the run detail

When the fetched report has a run-only status (potential, candidate, in_progress, pending_input, or failed), this branch still sends it to /code/inbox/reports/$reportId because only PRs are special-cased. Those reports are excluded from the Reports tab by isReportTabReport, and the run detail route is the one that renders the task log, so clicking a scout linked-report chip for an active/failed run lands on the wrong inbox surface. Add a run-status branch before the reports fallback.

Useful? React with 👍 / 👎.

}
log.info(`Successfully opened report: ${report.id}`);
} catch (error) {
log.error("Unexpected error opening report:", error);
toast.error("Failed to open report");
}
},
[client, queryClient, resetFilters],
);
}
32 changes: 25 additions & 7 deletions packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import { CaretRightIcon, CompassIcon } from "@phosphor-icons/react";
import type { ScoutEmission } from "@posthog/api-client/posthog-client";
import type {
LinkedSignalReport,
ScoutEmission,
} from "@posthog/api-client/posthog-client";
import { ANALYTICS_EVENTS } from "@posthog/shared";
import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer";
import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp";
import { track } from "@posthog/ui/shell/analytics";
import { Box, Flex, Text } from "@radix-ui/themes";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { SeverityBadge } from "./ScoutBadges";
import { ScoutLinkedReportChip } from "./ScoutLinkedReportChip";

export function ScoutEmissionCard({
emission,
skillName,
actions,
footerEnd,
linkedReport,
defaultExpanded = false,
highlighted = false,
}: {
Expand All @@ -23,6 +28,12 @@ export function ScoutEmissionCard({
actions?: ReactNode;
/** Replaces the default pipeline note at the footer's right edge. */
footerEnd?: ReactNode;
/**
* The inbox report this finding's signal grouped into, when resolved by the
* reverse lookup. Renders a chip linking to it; absent/undefined falls back
* to the generic pipeline note.
*/
linkedReport?: LinkedSignalReport | null;
defaultExpanded?: boolean;
/** True when a shared finding link targets this card – scrolls it into view. */
highlighted?: boolean;
Expand Down Expand Up @@ -89,14 +100,21 @@ export function ScoutEmissionCard({
className="border-t border-t-(--gray-5) text-[11px] text-gray-10"
>
<Text className="font-mono text-[11px]">{emission.finding_id}</Text>
{linkedReport ? (
<ScoutLinkedReportChip
report={linkedReport}
skillName={skillName}
/>
) : null}
{actions}
<span className="flex-1" />
{footerEnd ?? (
<Text className="text-[11px] text-gray-9">
Sent to the signals pipeline – report assignment isn&apos;t
traceable here yet
</Text>
)}
{footerEnd ??
(linkedReport ? null : (
<Text className="text-[11px] text-gray-9">
Sent to the signals pipeline – report assignment isn&apos;t
traceable here yet
</Text>
))}
</Flex>
) : null}
</Box>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { TrayIcon } from "@phosphor-icons/react";
import type { LinkedSignalReport } from "@posthog/api-client/posthog-client";
import { ANALYTICS_EVENTS } from "@posthog/shared";
import { useOpenInboxReport } from "@posthog/ui/features/inbox/hooks/useOpenInboxReport";
import { track } from "@posthog/ui/shell/analytics";
import { Text } from "@radix-ui/themes";
import { useState } from "react";

/**
* Footer chip on a scout emission card linking to the inbox report this finding
* grouped into. Best effort: only rendered when the reverse lookup resolved a
* report. Clicking opens the report in the inbox via the shared open-report
* flow (fetch by id, seed cache, navigate to the right tab).
*/
export function ScoutLinkedReportChip({
report,
skillName,
}: {
report: LinkedSignalReport;
/** The emitting scout, attached to analytics when known. */
skillName?: string;
}) {
const openReport = useOpenInboxReport();
const [opening, setOpening] = useState(false);

return (
<button
type="button"
disabled={opening}
onClick={async () => {
track(ANALYTICS_EVENTS.SCOUT_ACTION, {
action_type: "open_linked_report",
surface: "scout_detail",
skill_name: skillName,
report_status: report.status,
});
setOpening(true);
try {
await openReport(report.id);
} finally {
setOpening(false);
}
}}
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"
title={report.title ?? "View linked report"}
>
<TrayIcon size={12} className="shrink-0" />
<Text className="truncate text-[11px]">
{report.title ? `In report: ${report.title}` : "View linked report"}
</Text>
</button>
);
}
Loading
Loading