diff --git a/packages/core/src/scouts/scoutFindings.test.ts b/packages/core/src/scouts/scoutFindings.test.ts new file mode 100644 index 0000000000..425662e462 --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.test.ts @@ -0,0 +1,287 @@ +import type { + ScoutEmission, + ScoutEmissionReportLink, + ScoutRun, +} from "@posthog/api-client/posthog-client"; +import { describe, expect, it } from "vitest"; +import { + availableScoutsFromRows, + buildScoutFindingRows, + filterAndSortScoutFindings, + MAX_FLEET_EMITTED_RUNS, + mostRecentEmittedRuns, + SCOUT_FINDINGS_SCOUT_FILTER_ALL, + SCOUT_FINDINGS_SEVERITY_FILTER_ALL, + type ScoutFindingsFilter, + type ScoutFindingsSortKey, + summarizeEmittedRuns, + summarizeScoutFindingRows, +} from "./scoutFindings"; + +function makeRun(overrides: Partial = {}): ScoutRun { + return { + run_id: "run-1", + skill_name: "signals-scout-error-tracking", + skill_version: 3, + status: "completed", + started_at: "2026-06-10T11:00:00Z", + completed_at: "2026-06-10T11:02:00Z", + task_id: null, + task_run_id: null, + task_url: null, + summary: "EMITTED a finding.", + emitted_count: 1, + emitted_finding_ids: [], + ...overrides, + }; +} + +function makeEmission(overrides: Partial = {}): ScoutEmission { + return { + id: "em-1", + run_id: "run-1", + finding_id: "finding-1", + description: "Error rate spiked on checkout", + weight: 1, + confidence: 0.8, + severity: "P1", + tags: [], + source_id: "src-1", + emitted_at: "2026-06-10T11:02:00Z", + ...overrides, + }; +} + +const ALL_FILTER: ScoutFindingsFilter = { + search: "", + scout: SCOUT_FINDINGS_SCOUT_FILTER_ALL, + severity: SCOUT_FINDINGS_SEVERITY_FILTER_ALL, + sort: "newest", +}; + +describe("mostRecentEmittedRuns", () => { + it("drops runs that emitted nothing", () => { + const runs = [ + makeRun({ run_id: "a", emitted_count: 2 }), + makeRun({ run_id: "b", emitted_count: 0 }), + makeRun({ run_id: "c", emitted_count: null }), + ]; + expect(mostRecentEmittedRuns(runs).map((r) => r.run_id)).toEqual(["a"]); + }); + + it("orders by completion, falling back to start time", () => { + const runs = [ + makeRun({ run_id: "older", completed_at: "2026-06-10T10:00:00Z" }), + makeRun({ run_id: "newer", completed_at: "2026-06-10T11:30:00Z" }), + makeRun({ + run_id: "no-complete", + completed_at: null, + started_at: "2026-06-10T12:00:00Z", + }), + ]; + expect(mostRecentEmittedRuns(runs).map((r) => r.run_id)).toEqual([ + "no-complete", + "newer", + "older", + ]); + }); + + it("caps the result", () => { + const runs = Array.from({ length: MAX_FLEET_EMITTED_RUNS + 10 }, (_, i) => + makeRun({ + run_id: `run-${i}`, + completed_at: `2026-06-10T${String(i % 24).padStart(2, "0")}:00:00Z`, + }), + ); + expect(mostRecentEmittedRuns(runs)).toHaveLength(MAX_FLEET_EMITTED_RUNS); + }); +}); + +describe("buildScoutFindingRows", () => { + it("joins emissions to their run and report by source_id", () => { + const run = makeRun({ run_id: "run-1" }); + const emission = makeEmission({ run_id: "run-1", source_id: "src-1" }); + const links: ScoutEmissionReportLink[] = [ + { + finding_id: "finding-1", + source_id: "src-1", + report: { id: "rep-1", title: "Checkout errors", status: "potential" }, + }, + ]; + const rows = buildScoutFindingRows([emission], [run], links); + expect(rows).toHaveLength(1); + expect(rows[0].run.run_id).toBe("run-1"); + expect(rows[0].linkedReport?.id).toBe("rep-1"); + }); + + it("drops emissions whose run is not in the window", () => { + const rows = buildScoutFindingRows( + [makeEmission({ run_id: "missing" })], + [makeRun({ run_id: "run-1" })], + [], + ); + expect(rows).toEqual([]); + }); + + it("leaves linkedReport null when there is no matching link or report", () => { + const rows = buildScoutFindingRows( + [makeEmission({ run_id: "run-1", source_id: "src-1" })], + [makeRun({ run_id: "run-1" })], + [{ finding_id: "finding-1", source_id: "src-1", report: null }], + ); + expect(rows[0].linkedReport).toBeNull(); + }); +}); + +describe("filterAndSortScoutFindings", () => { + const run = makeRun({ run_id: "run-1", skill_name: "signals-scout-apm" }); + const other = makeRun({ + run_id: "run-2", + skill_name: "signals-scout-logs", + }); + const rows = buildScoutFindingRows( + [ + makeEmission({ + id: "a", + run_id: "run-1", + source_id: "s-a", + severity: "P0", + confidence: 0.4, + description: "latency regression", + emitted_at: "2026-06-10T09:00:00Z", + }), + makeEmission({ + id: "b", + run_id: "run-2", + source_id: "s-b", + severity: "P3", + confidence: 0.9, + description: "noisy log volume", + emitted_at: "2026-06-10T11:00:00Z", + }), + ], + [run, other], + [], + ); + + it.each<{ + name: string; + sort: ScoutFindingsSortKey; + expected: string[]; + }>([ + { name: "newest first (default)", sort: "newest", expected: ["b", "a"] }, + { name: "oldest first", sort: "oldest", expected: ["a", "b"] }, + { + name: "severity (most severe first)", + sort: "severity", + expected: ["a", "b"], + }, + { + name: "confidence (highest first)", + sort: "confidence", + expected: ["b", "a"], + }, + ])("sorts by $name", ({ sort, expected }) => { + expect( + filterAndSortScoutFindings(rows, { ...ALL_FILTER, sort }).map( + (r) => r.emission.id, + ), + ).toEqual(expected); + }); + + it("filters by scout", () => { + expect( + filterAndSortScoutFindings(rows, { + ...ALL_FILTER, + scout: "signals-scout-logs", + }).map((r) => r.emission.id), + ).toEqual(["b"]); + }); + + it("filters by severity", () => { + expect( + filterAndSortScoutFindings(rows, { + ...ALL_FILTER, + severity: "P0", + }).map((r) => r.emission.id), + ).toEqual(["a"]); + }); + + it("searches finding text and prettified scout name", () => { + expect( + filterAndSortScoutFindings(rows, { + ...ALL_FILTER, + search: "latency", + }).map((r) => r.emission.id), + ).toEqual(["a"]); + // "Apm" comes from prettifying signals-scout-apm. + expect( + filterAndSortScoutFindings(rows, { ...ALL_FILTER, search: "apm" }).map( + (r) => r.emission.id, + ), + ).toEqual(["a"]); + }); +}); + +describe("summaries", () => { + it("summarizeEmittedRuns sums emitted counts and distinct scouts", () => { + const summary = summarizeEmittedRuns([ + makeRun({ run_id: "a", skill_name: "x", emitted_count: 2 }), + makeRun({ run_id: "b", skill_name: "x", emitted_count: 1 }), + makeRun({ run_id: "c", skill_name: "y", emitted_count: 3 }), + makeRun({ run_id: "d", skill_name: "z", emitted_count: 0 }), + ]); + expect(summary.totalCount).toBe(6); + expect(summary.scoutCount).toBe(2); + }); + + it("summarizeScoutFindingRows counts rows, distinct scouts, latest", () => { + const rows = buildScoutFindingRows( + [ + makeEmission({ + id: "a", + run_id: "run-1", + emitted_at: "2026-06-10T09:00:00Z", + }), + makeEmission({ + id: "b", + run_id: "run-2", + emitted_at: "2026-06-10T12:00:00Z", + }), + ], + [ + makeRun({ run_id: "run-1", skill_name: "x" }), + makeRun({ run_id: "run-2", skill_name: "y" }), + ], + [], + ); + const summary = summarizeScoutFindingRows(rows); + expect(summary.totalCount).toBe(2); + expect(summary.scoutCount).toBe(2); + expect(summary.latestEmittedAt).toBe("2026-06-10T12:00:00Z"); + }); + + it("availableScoutsFromRows returns per-scout counts sorted by label", () => { + const rows = buildScoutFindingRows( + [ + makeEmission({ id: "a", run_id: "run-1" }), + makeEmission({ id: "b", run_id: "run-1" }), + makeEmission({ id: "c", run_id: "run-2" }), + ], + [ + makeRun({ run_id: "run-1", skill_name: "signals-scout-logs" }), + makeRun({ run_id: "run-2", skill_name: "signals-scout-apm" }), + ], + [], + ); + const scouts = availableScoutsFromRows(rows); + // Sorted by prettified label: "Apm" before "Logs". + expect(scouts.map((s) => s.skillName)).toEqual([ + "signals-scout-apm", + "signals-scout-logs", + ]); + expect( + scouts.find((s) => s.skillName === "signals-scout-logs")?.count, + ).toBe(2); + }); +}); diff --git a/packages/core/src/scouts/scoutFindings.ts b/packages/core/src/scouts/scoutFindings.ts new file mode 100644 index 0000000000..1def6920c6 --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.ts @@ -0,0 +1,228 @@ +import type { + LinkedSignalReport, + ScoutEmission, + ScoutEmissionReportLink, + ScoutRun, +} from "@posthog/api-client/posthog-client"; +import { prettifyScoutSkillName } from "./scoutPresentation"; + +/** + * Cross-fleet findings logic — the pure counterpart of the cloud `findingsLogic`. + * Joins every recently-emitted scout finding to its run and the inbox report it + * grouped into, then filters/sorts the flattened list. Kept host-agnostic so the + * UI hook only wires queries; everything decision-shaped lives here and is unit + * tested. + */ + +/** + * Fleet-wide cap on emitted runs we pull findings for. The runs window already + * bounds this by time; this is a belt-and-braces ceiling so a burst of emitting + * runs can't fan out into an unbounded batch request. + */ +export const MAX_FLEET_EMITTED_RUNS = 120; + +export const SCOUT_FINDINGS_SCOUT_FILTER_ALL = "all"; +export const SCOUT_FINDINGS_SEVERITY_FILTER_ALL = "all"; + +/** Severity options offered in the filter, most severe first. */ +export const SCOUT_FINDINGS_SEVERITY_OPTIONS = [ + "P0", + "P1", + "P2", + "P3", + "P4", +] as const; + +export type ScoutFindingsSortKey = + | "newest" + | "oldest" + | "severity" + | "confidence"; + +export interface ScoutFindingRow { + emission: ScoutEmission; + /** The run that emitted the finding — carries skill_name + the task-run link. */ + run: ScoutRun; + /** The inbox report this finding's signal grouped into, when resolved. */ + linkedReport: LinkedSignalReport | null; +} + +export interface ScoutFindingsFilter { + search: string; + /** A `skill_name`, or {@link SCOUT_FINDINGS_SCOUT_FILTER_ALL}. */ + scout: string; + /** A severity (`P0`–`P4`), or {@link SCOUT_FINDINGS_SEVERITY_FILTER_ALL}. */ + severity: string; + sort: ScoutFindingsSortKey; +} + +export interface ScoutFilterOption { + skillName: string; + label: string; + count: number; +} + +export interface ScoutFindingsSummary { + totalCount: number; + scoutCount: number; + latestEmittedAt: string | null; +} + +/** Lowest number = most severe, so the severity sort is a plain ascending compare. */ +const SEVERITY_RANK: Record = { + P0: 0, + P1: 1, + P2: 2, + P3: 3, + P4: 4, +}; + +/** Null/unknown severity sinks below the explicit ranks. */ +function severityRank(severity: string | null): number { + if (severity == null) return 5; + return SEVERITY_RANK[severity] ?? 5; +} + +/** + * The most-recently-emitted runs across the fleet, newest first, capped at + * {@link MAX_FLEET_EMITTED_RUNS}. A run can complete (and emit) later than one + * started after it, so order by completion and fall back to start time. + */ +export function mostRecentEmittedRuns(runs: ScoutRun[]): ScoutRun[] { + return runs + .filter((run) => (run.emitted_count ?? 0) > 0) + .sort((a, b) => + (b.completed_at ?? b.started_at ?? "").localeCompare( + a.completed_at ?? a.started_at ?? "", + ), + ) + .slice(0, MAX_FLEET_EMITTED_RUNS); +} + +/** + * Cheap summary for the fleet callout — derived from the runs window alone, so + * it never triggers the per-run emissions fetch the findings page does on open. + */ +export function summarizeEmittedRuns(runs: ScoutRun[]): ScoutFindingsSummary { + const emitted = mostRecentEmittedRuns(runs); + let totalCount = 0; + const scouts = new Set(); + let latestEmittedAt: string | null = null; + for (const run of emitted) { + totalCount += run.emitted_count ?? 0; + scouts.add(run.skill_name); + const at = run.completed_at ?? run.started_at; + if (at && (!latestEmittedAt || at > latestEmittedAt)) latestEmittedAt = at; + } + return { totalCount, scoutCount: scouts.size, latestEmittedAt }; +} + +/** + * Join emissions back to their run and the report their signal grouped into. + * Emissions whose run isn't in the window are dropped (the run set is the source + * of truth for what's shown). Reports are keyed by `source_id`. + */ +export function buildScoutFindingRows( + emissions: ScoutEmission[], + emittedRuns: ScoutRun[], + reportLinks: ScoutEmissionReportLink[], +): ScoutFindingRow[] { + const runsById = new Map(emittedRuns.map((run) => [run.run_id, run])); + const reportBySourceId = new Map(); + for (const link of reportLinks) { + if (link.report) reportBySourceId.set(link.source_id, link.report); + } + const rows: ScoutFindingRow[] = []; + for (const emission of emissions) { + const run = runsById.get(emission.run_id); + if (!run) continue; + rows.push({ + emission, + run, + linkedReport: reportBySourceId.get(emission.source_id) ?? null, + }); + } + return rows; +} + +/** Distinct scouts present in the rows, with a per-scout count, for the filter. */ +export function availableScoutsFromRows( + rows: ScoutFindingRow[], +): ScoutFilterOption[] { + const counts = new Map(); + for (const row of rows) { + counts.set(row.run.skill_name, (counts.get(row.run.skill_name) ?? 0) + 1); + } + return [...counts.entries()] + .map(([skillName, count]) => ({ + skillName, + label: prettifyScoutSkillName(skillName), + count, + })) + .sort((a, b) => a.label.localeCompare(b.label)); +} + +const byNewest = (a: ScoutFindingRow, b: ScoutFindingRow): number => + (b.emission.emitted_at ?? "").localeCompare(a.emission.emitted_at ?? ""); + +/** + * Visible set: search (over finding text + prettified scout name) + scout + + * severity, then sort by the chosen key. + */ +export function filterAndSortScoutFindings( + rows: ScoutFindingRow[], + filter: ScoutFindingsFilter, +): ScoutFindingRow[] { + const needle = filter.search.trim().toLowerCase(); + const filtered = rows.filter((row) => { + if ( + filter.scout !== SCOUT_FINDINGS_SCOUT_FILTER_ALL && + row.run.skill_name !== filter.scout + ) { + return false; + } + if ( + filter.severity !== SCOUT_FINDINGS_SEVERITY_FILTER_ALL && + row.emission.severity !== filter.severity + ) { + return false; + } + if (needle) { + const haystack = + `${row.emission.description ?? ""} ${prettifyScoutSkillName(row.run.skill_name)}`.toLowerCase(); + if (!haystack.includes(needle)) return false; + } + return true; + }); + return filtered.sort((a, b) => { + if (filter.sort === "oldest") return -byNewest(a, b); + if (filter.sort === "severity") { + const diff = + severityRank(a.emission.severity) - severityRank(b.emission.severity); + return diff !== 0 ? diff : byNewest(a, b); + } + if (filter.sort === "confidence") { + const diff = (b.emission.confidence ?? 0) - (a.emission.confidence ?? 0); + return diff !== 0 ? diff : byNewest(a, b); + } + return byNewest(a, b); + }); +} + +/** Header tallies, computed from the joined rows (not the cheap window sum). */ +export function summarizeScoutFindingRows( + rows: ScoutFindingRow[], +): ScoutFindingsSummary { + const scouts = new Set(); + let latestEmittedAt: string | null = null; + for (const row of rows) { + scouts.add(row.run.skill_name); + const at = row.emission.emitted_at; + if (at && (!latestEmittedAt || at > latestEmittedAt)) latestEmittedAt = at; + } + return { + totalCount: rows.length, + scoutCount: scouts.size, + latestEmittedAt, + }; +} diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index b174c15a1f..5266b2f578 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -662,7 +662,11 @@ export type ScoutChatType = | "finding_discuss" | "author_scout"; -export type ScoutSurface = "fleet_list" | "scout_detail" | "empty_state"; +export type ScoutSurface = + | "fleet_list" + | "scout_detail" + | "empty_state" + | "scout_findings"; export type ScoutActionType = | "expand_run" @@ -678,7 +682,10 @@ export type ScoutActionType = | "filter_runs" | "toggle_hide_disabled" | "open_settings" - | "close_settings"; + | "close_settings" + | "open_findings" + | "filter_findings" + | "sort_findings"; export interface ScoutFleetViewedProperties { scout_count: number; diff --git a/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx b/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx new file mode 100644 index 0000000000..45e6ac0440 --- /dev/null +++ b/packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx @@ -0,0 +1,69 @@ +import { ArrowRightIcon, SparkleIcon } from "@phosphor-icons/react"; +import { summarizeEmittedRuns } from "@posthog/core/scouts/scoutFindings"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { navigateToScoutFindings } from "@posthog/ui/router/navigationBridge"; +import { track } from "@posthog/ui/shell/analytics"; +import { Flex, Text } from "@radix-ui/themes"; +import { useMemo } from "react"; +import { useScoutRuns } from "../hooks/useScoutRuns"; + +/** + * Findings entry point for the scout fleet section. Advertises the troop's recent + * findings (count · scouts · recency) and links into the cross-fleet findings + * page. Reads the cheap runs-window summary so it never triggers the per-run + * emissions fetch the page does on open. Renders nothing until there's at least + * one finding. + * + * Mirrors the PostHog Cloud `FleetFindingsCallout`. + */ +export function FleetFindingsCallout() { + const { data: runsWindow, isLoading } = useScoutRuns(); + const summary = useMemo( + () => summarizeEmittedRuns(runsWindow?.runs ?? []), + [runsWindow], + ); + + // Hold until the first runs load settles, then only show when there's + // something to read. + if (isLoading || summary.totalCount === 0) { + return null; + } + + return ( + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx index b2197a74af..14b502ee9b 100644 --- a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx @@ -15,6 +15,7 @@ import { ScoutLinkedReportChip } from "./ScoutLinkedReportChip"; export function ScoutEmissionCard({ emission, skillName, + scoutLabel, actions, footerEnd, linkedReport, @@ -24,6 +25,12 @@ export function ScoutEmissionCard({ emission: ScoutEmission; /** The emitting scout, attached to analytics events when known. */ skillName?: string; + /** + * Prettified emitting-scout name, shown in the header. Set on cross-fleet + * surfaces (the findings page) where cards from different scouts are mixed; + * omit on a single-scout surface where the scout is already obvious. + */ + scoutLabel?: string; /** Interactive controls shown after the finding id at the footer's left. */ actions?: ReactNode; /** Content pinned to the footer's right edge, e.g. the task-run link. */ @@ -81,6 +88,17 @@ export function ScoutEmissionCard({ confidence {Math.round(emission.confidence * 100)}% + {scoutLabel ? ( + + + + {scoutLabel} + + + ) : null} diff --git a/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx new file mode 100644 index 0000000000..92e36f4924 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx @@ -0,0 +1,366 @@ +import { + ArrowLeftIcon, + MagnifyingGlassIcon, + SparkleIcon, +} from "@phosphor-icons/react"; +import { + availableScoutsFromRows, + filterAndSortScoutFindings, + SCOUT_FINDINGS_SCOUT_FILTER_ALL, + SCOUT_FINDINGS_SEVERITY_FILTER_ALL, + SCOUT_FINDINGS_SEVERITY_OPTIONS, + type ScoutFindingsSortKey, + summarizeScoutFindingRows, +} from "@posthog/core/scouts/scoutFindings"; +import { prettifyScoutSkillName } from "@posthog/core/scouts/scoutPresentation"; +import { SCOUT_RUNS_WINDOW_SPAN } from "@posthog/core/scouts/scoutRunsWindow"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { track } from "@posthog/ui/shell/analytics"; +import { Box, Flex, Select, Text, TextField } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useScoutFindings } from "../hooks/useScoutFindings"; +import { ScoutEmissionCard } from "./ScoutEmissionCard"; + +const SORT_OPTIONS: { value: ScoutFindingsSortKey; label: string }[] = [ + { value: "newest", label: "Newest" }, + { value: "oldest", label: "Oldest" }, + { value: "severity", label: "Severity" }, + { value: "confidence", label: "Confidence" }, +]; + +/** + * Cross-fleet findings browser — every finding the troop emitted recently in one + * place, newest first, searchable and filterable by scout/severity with a sort + * toggle. Reuses the per-scout {@link ScoutEmissionCard} with the emitting + * scout's name shown. Read-only: acting on a finding happens in its inbox report. + * + * Mirrors the PostHog Cloud `FindingsPanel`, kept structurally aligned so the two + * surfaces stay in parity as the backend evolves. + */ +export function ScoutFindingsView() { + const { + rows, + hasLoadedOnce, + runsError, + emissionsError, + emissionsFetching, + refetch, + } = useScoutFindings(); + + const [searchText, setSearchText] = useState(""); + const [scoutFilter, setScoutFilter] = useState( + SCOUT_FINDINGS_SCOUT_FILTER_ALL, + ); + const [severityFilter, setSeverityFilter] = useState( + SCOUT_FINDINGS_SEVERITY_FILTER_ALL, + ); + const [sortKey, setSortKey] = useState("newest"); + + const headerContent = useMemo( + () => ( + + + + Scout findings + + + ), + [], + ); + useSetHeaderContent(headerContent); + + const availableScouts = useMemo(() => availableScoutsFromRows(rows), [rows]); + const summary = useMemo(() => summarizeScoutFindingRows(rows), [rows]); + const filteredRows = useMemo( + () => + filterAndSortScoutFindings(rows, { + search: searchText, + scout: scoutFilter, + severity: severityFilter, + sort: sortKey, + }), + [rows, searchText, scoutFilter, severityFilter, sortKey], + ); + + const isFiltering = + searchText.trim().length > 0 || + scoutFilter !== SCOUT_FINDINGS_SCOUT_FILTER_ALL || + severityFilter !== SCOUT_FINDINGS_SEVERITY_FILTER_ALL; + + // A failed initial load with nothing on screen, vs a stale list whose later + // refresh failed — the former is a full error state, the latter a warning that + // the list may be incomplete. + const loadFailed = emissionsError || runsError; + + return ( + + + + + Scouts + + + + + Scout findings + + + + Every signal your scouts have emitted recently, in one place — newest + first. See what's been surfaced across the whole troop, which + scout found it, and the inbox report it fed into. + + + {summary.totalCount > 0 ? ( + <> + + {summary.totalCount} finding + {summary.totalCount === 1 ? "" : "s"} · {summary.scoutCount}{" "} + scout{summary.scoutCount === 1 ? "" : "s"} + + {summary.latestEmittedAt ? ( + <> + · latest + + + ) : null} + + ) : null} + + + Covers findings from the most recent {SCOUT_RUNS_WINDOW_SPAN} of troop + runs. Older findings live on in the inbox reports they produced. + + + +
+
+ + + setSearchText(event.target.value)} + size="2" + className="min-w-[12rem] flex-1" + > + + + + + + { + setScoutFilter(value); + track(ANALYTICS_EVENTS.SCOUT_ACTION, { + action_type: "filter_findings", + surface: "scout_findings", + filter: value, + }); + }} + > + + + + All scouts + + {availableScouts.map((scout) => ( + + {scout.label} ({scout.count}) + + ))} + + + + { + setSeverityFilter(value); + track(ANALYTICS_EVENTS.SCOUT_ACTION, { + action_type: "filter_findings", + surface: "scout_findings", + filter: `severity:${value}`, + }); + }} + > + + + + All severities + + {SCOUT_FINDINGS_SEVERITY_OPTIONS.map((severity) => ( + + {severity} + + ))} + + + + { + const next = value as ScoutFindingsSortKey; + setSortKey(next); + track(ANALYTICS_EVENTS.SCOUT_ACTION, { + action_type: "sort_findings", + surface: "scout_findings", + filter: next, + }); + }} + > + + + {SORT_OPTIONS.map((option) => ( + + Sort: {option.label} + + ))} + + + + + {hasLoadedOnce && + (emissionsError || runsError) && + emissionsFetching === false && + rows.length > 0 ? ( + // A later poll/retry failed while a prior set is still on screen. + // The list may be incomplete — warn rather than show it silently. + + + Some findings couldn't be loaded, so this list may be + incomplete. + + + + ) : null} + + + +
+
+
+ ); +} + +function FindingsBody({ + hasLoadedOnce, + loadFailed, + rowCount, + filteredRows, + isFiltering, + onRetry, +}: { + hasLoadedOnce: boolean; + loadFailed: boolean; + rowCount: number; + filteredRows: ReturnType; + isFiltering: boolean; + onRetry: () => void; +}) { + if (!hasLoadedOnce) { + return ( + + {[0, 1, 2].map((key) => ( + + ))} + + ); + } + + if (loadFailed && rowCount === 0) { + return ( + + + Couldn't load findings. The scout API may be unavailable or this + project may not be enrolled yet. + + + + ); + } + + if (filteredRows.length === 0) { + return ( + + {isFiltering + ? "No findings match your search and filters." + : "Your scouts haven't emitted any findings yet. As they scan your project, what they surface shows up here."} + + ); + } + + return ( + + {filteredRows.map((row) => ( + + ))} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index bb978a3773..ec51c7bb3d 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -31,6 +31,7 @@ import { useScoutChatTask } from "../hooks/useScoutChatTask"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; +import { FleetFindingsCallout } from "./FleetFindingsCallout"; import { FleetMemoryCallout } from "./FleetMemoryCallout"; import { ScoutAlphaBanner } from "./ScoutAlphaBanner"; import { ScoutHelperSkillLinks } from "./ScoutHelperSkillLinks"; @@ -228,6 +229,9 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { /> + {/* Findings: renders only once scouts have emitted something. */} + + {/* Fleet memory: renders only once scouts have written scratchpad notes. */} diff --git a/packages/ui/src/features/scouts/hooks/useScoutFindings.ts b/packages/ui/src/features/scouts/hooks/useScoutFindings.ts new file mode 100644 index 0000000000..03a7a2069c --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutFindings.ts @@ -0,0 +1,84 @@ +import { + buildScoutFindingRows, + mostRecentEmittedRuns, + type ScoutFindingRow, +} from "@posthog/core/scouts/scoutFindings"; +import type { ScoutRunsWindow } from "@posthog/core/scouts/scoutRunsWindow"; +import { useMemo } from "react"; +import { useScoutEmissionReports } from "./useScoutEmissionReports"; +import { useScoutRunEmissions } from "./useScoutRunEmissions"; +import { useScoutRuns } from "./useScoutRuns"; + +export interface ScoutFindingsData { + rows: ScoutFindingRow[]; + runsWindow: ScoutRunsWindow | undefined; + /** True once the runs window and (if any) emissions have settled at least once. */ + hasLoadedOnce: boolean; + /** The runs window load failed — the page has no run set to fetch findings for. */ + runsError: boolean; + /** The batched emissions fetch (the page's actual content) failed. */ + emissionsError: boolean; + /** A poll/retry of emissions is in flight while a prior set may still be shown. */ + emissionsFetching: boolean; + /** Re-run the runs window plus the emissions + report-link batches. */ + refetch: () => void; +} + +/** + * Fleet-wide findings — the cross-troop counterpart of the per-scout view. + * Reuses the shared {@link useScoutRuns} window, narrows it to recently-emitted + * runs in core, then fetches their findings + report links in two batched + * requests and flattens them into one list the page filters/sorts. The runs + * query is cache-shared with the fleet section, so opening this page never + * double-fetches the window. + */ +export function useScoutFindings(): ScoutFindingsData { + const { + data: runsWindow, + isLoading: runsLoading, + isError: runsError, + refetch: refetchRuns, + } = useScoutRuns(); + + const emittedRuns = useMemo( + () => mostRecentEmittedRuns(runsWindow?.runs ?? []), + [runsWindow], + ); + const runIds = useMemo( + () => emittedRuns.map((run) => run.run_id), + [emittedRuns], + ); + + const emissionsQuery = useScoutRunEmissions(runIds); + const reportsQuery = useScoutEmissionReports(runIds); + + const rows = useMemo( + () => + buildScoutFindingRows( + emissionsQuery.data ?? [], + emittedRuns, + reportsQuery.data ?? [], + ), + [emissionsQuery.data, emittedRuns, reportsQuery.data], + ); + + // "Loaded once" distinguishes "not loaded yet" (skeleton) from "loaded, empty". + // With no emitted runs there's nothing to fetch, so the runs load alone settles + // it; otherwise wait for the emissions batch to have fetched at least once. + const hasLoadedOnce = + !runsLoading && (runIds.length === 0 || emissionsQuery.isFetched); + + return { + rows, + runsWindow, + hasLoadedOnce, + runsError, + emissionsError: emissionsQuery.isError, + emissionsFetching: emissionsQuery.isFetching, + refetch: () => { + void refetchRuns(); + void emissionsQuery.refetch(); + void reportsQuery.refetch(); + }, + }; +} diff --git a/packages/ui/src/router/navigationBridge.ts b/packages/ui/src/router/navigationBridge.ts index 5a9849a6c4..f5859e384e 100644 --- a/packages/ui/src/router/navigationBridge.ts +++ b/packages/ui/src/router/navigationBridge.ts @@ -140,6 +140,10 @@ export function navigateToScoutDetail( }); } +export function navigateToScoutFindings(): void { + void getRouterOrNull()?.navigate({ to: "/code/agents/scouts/findings" }); +} + export function navigateToAgents(): void { void getRouterOrNull()?.navigate({ to: "/code/agents" }); } diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 83e9deea9e..c378c10fa9 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -59,6 +59,7 @@ import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbo import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' import { Route as CodeInboxDismissedReportIdRouteImport } from './routes/code/inbox/dismissed.$reportId' import { Route as CodeAgentsScoutsScratchpadRouteImport } from './routes/code/agents/scouts.scratchpad' +import { Route as CodeAgentsScoutsFindingsRouteImport } from './routes/code/agents/scouts.findings' import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' import { Route as CodeAgentsApplicationsApprovalsRouteImport } from './routes/code/agents/applications/approvals' import { Route as CodeAgentsApplicationsIdOrSlugRouteImport } from './routes/code/agents/applications/$idOrSlug' @@ -331,6 +332,12 @@ const CodeAgentsScoutsScratchpadRoute = path: '/scratchpad', getParentRoute: () => CodeAgentsScoutsRoute, } as any) +const CodeAgentsScoutsFindingsRoute = + CodeAgentsScoutsFindingsRouteImport.update({ + id: '/findings', + path: '/findings', + getParentRoute: () => CodeAgentsScoutsRoute, + } as any) const CodeAgentsScoutsSkillNameRoute = CodeAgentsScoutsSkillNameRouteImport.update({ id: '/$skillName', @@ -450,6 +457,7 @@ export interface FileRoutesByFullPath { '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -504,6 +512,7 @@ export interface FileRoutesByTo { '/code/inbox': typeof CodeInboxIndexRoute '/website/$channelId': typeof WebsiteChannelIdIndexRoute '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -570,6 +579,7 @@ export interface FileRoutesById { '/code/agents/applications/$idOrSlug': typeof CodeAgentsApplicationsIdOrSlugRouteWithChildren '/code/agents/applications/approvals': typeof CodeAgentsApplicationsApprovalsRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren + '/code/agents/scouts/findings': typeof CodeAgentsScoutsFindingsRoute '/code/agents/scouts/scratchpad': typeof CodeAgentsScoutsScratchpadRoute '/code/inbox/dismissed/$reportId': typeof CodeInboxDismissedReportIdRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -637,6 +647,7 @@ export interface FileRouteTypes { | '/code/agents/applications/$idOrSlug' | '/code/agents/applications/approvals' | '/code/agents/scouts/$skillName' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -691,6 +702,7 @@ export interface FileRouteTypes { | '/code/inbox' | '/website/$channelId' | '/code/agents/applications/approvals' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -756,6 +768,7 @@ export interface FileRouteTypes { | '/code/agents/applications/$idOrSlug' | '/code/agents/applications/approvals' | '/code/agents/scouts/$skillName' + | '/code/agents/scouts/findings' | '/code/agents/scouts/scratchpad' | '/code/inbox/dismissed/$reportId' | '/code/inbox/pulls/$reportId' @@ -1152,6 +1165,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsScratchpadRouteImport parentRoute: typeof CodeAgentsScoutsRoute } + '/code/agents/scouts/findings': { + id: '/code/agents/scouts/findings' + path: '/findings' + fullPath: '/code/agents/scouts/findings' + preLoaderRoute: typeof CodeAgentsScoutsFindingsRouteImport + parentRoute: typeof CodeAgentsScoutsRoute + } '/code/agents/scouts/$skillName': { id: '/code/agents/scouts/$skillName' path: '/$skillName' @@ -1360,12 +1380,14 @@ const CodeAgentsScoutsSkillNameRouteWithChildren = interface CodeAgentsScoutsRouteChildren { CodeAgentsScoutsSkillNameRoute: typeof CodeAgentsScoutsSkillNameRouteWithChildren + CodeAgentsScoutsFindingsRoute: typeof CodeAgentsScoutsFindingsRoute CodeAgentsScoutsScratchpadRoute: typeof CodeAgentsScoutsScratchpadRoute CodeAgentsScoutsIndexRoute: typeof CodeAgentsScoutsIndexRoute } const CodeAgentsScoutsRouteChildren: CodeAgentsScoutsRouteChildren = { CodeAgentsScoutsSkillNameRoute: CodeAgentsScoutsSkillNameRouteWithChildren, + CodeAgentsScoutsFindingsRoute: CodeAgentsScoutsFindingsRoute, CodeAgentsScoutsScratchpadRoute: CodeAgentsScoutsScratchpadRoute, CodeAgentsScoutsIndexRoute: CodeAgentsScoutsIndexRoute, } diff --git a/packages/ui/src/router/routes/code/agents/scouts.findings.tsx b/packages/ui/src/router/routes/code/agents/scouts.findings.tsx new file mode 100644 index 0000000000..1a619d90b7 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.findings.tsx @@ -0,0 +1,6 @@ +import { ScoutFindingsView } from "@posthog/ui/features/scouts/components/ScoutFindingsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts/findings")({ + component: ScoutFindingsView, +});