From 790013afbf7e799d55a80164dbb7be7c3d862dab Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 30 Jun 2026 21:54:04 +0100 Subject: [PATCH 1/3] feat(scouts): add cross-fleet scout findings page Port the cloud Inbox's scouts-modal FindingsPanel into the Code app as a new page at /code/agents/scouts/findings, reached via a callout in the Scouts section under Agents. It lists every finding the troop emitted recently in one place, newest-first, searchable and filterable by scout/severity with a sort toggle (newest/oldest/severity/confidence). Each card shows the emitting scout and a chip into the inbox report its signal grouped into. Reuses the existing scout data plumbing (useScoutRuns/useScoutRunEmissions/ useScoutEmissionReports, ScoutEmissionCard); the runs query is cache-shared with the fleet section so opening the page does not double-fetch. Pure join/filter/sort logic lives in core (scoutFindings.ts) with unit tests. --- .../core/src/scouts/scoutFindings.test.ts | 284 ++++++++++++++ packages/core/src/scouts/scoutFindings.ts | 229 +++++++++++ packages/shared/src/analytics-events.ts | 11 +- .../components/ConfigureAgentsSection.tsx | 2 + .../components/FleetFindingsCallout.tsx | 66 ++++ .../scouts/components/ScoutEmissionCard.tsx | 18 + .../scouts/components/ScoutFindingsView.tsx | 366 ++++++++++++++++++ .../features/scouts/hooks/useScoutFindings.ts | 84 ++++ packages/ui/src/router/navigationBridge.ts | 4 + packages/ui/src/router/routeTree.gen.ts | 22 ++ .../routes/code/agents/scouts.findings.tsx | 6 + 11 files changed, 1090 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/scouts/scoutFindings.test.ts create mode 100644 packages/core/src/scouts/scoutFindings.ts create mode 100644 packages/ui/src/features/scouts/components/FleetFindingsCallout.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutFindingsView.tsx create mode 100644 packages/ui/src/features/scouts/hooks/useScoutFindings.ts create mode 100644 packages/ui/src/router/routes/code/agents/scouts.findings.tsx diff --git a/packages/core/src/scouts/scoutFindings.test.ts b/packages/core/src/scouts/scoutFindings.test.ts new file mode 100644 index 0000000000..3d7ae45cd8 --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.test.ts @@ -0,0 +1,284 @@ +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, + 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("sorts newest first by default", () => { + expect( + filterAndSortScoutFindings(rows, ALL_FILTER).map((r) => r.emission.id), + ).toEqual(["b", "a"]); + }); + + it("sorts by severity (most severe first)", () => { + expect( + filterAndSortScoutFindings(rows, { ...ALL_FILTER, sort: "severity" }).map( + (r) => r.emission.id, + ), + ).toEqual(["a", "b"]); + }); + + it("sorts by confidence (highest first)", () => { + expect( + filterAndSortScoutFindings(rows, { + ...ALL_FILTER, + sort: "confidence", + }).map((r) => r.emission.id), + ).toEqual(["b", "a"]); + }); + + 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..b5dd4e9543 --- /dev/null +++ b/packages/core/src/scouts/scoutFindings.ts @@ -0,0 +1,229 @@ +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) + .slice() + .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.slice().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/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 402502ef97..20ac085abd 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -32,6 +32,7 @@ import { useRepositoryIntegration, useUserRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; +import { FleetFindingsCallout } from "@posthog/ui/features/scouts/components/FleetFindingsCallout"; import { ScoutsFleetSection } from "@posthog/ui/features/scouts/components/ScoutsFleetSection"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; @@ -148,6 +149,7 @@ export function ConfigureAgentsSection() { } > + 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..353de253bb --- /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 && + 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/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, +}); From 895dea6e4b4c993e3ab0c0bbc8b97fff564fafda Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 30 Jun 2026 21:56:59 +0100 Subject: [PATCH 2/3] fix(scouts): move findings callout into the fleet pop-down Place the Scout findings callout inside the expanded scout fleet section, just above the scratchpad (FleetMemoryCallout) callout, instead of below the fleet in the outer Agents section. Match its styling to the adjacent scratchpad callout. --- .../features/inbox/components/ConfigureAgentsSection.tsx | 2 -- .../features/scouts/components/FleetFindingsCallout.tsx | 7 +++++-- .../src/features/scouts/components/ScoutsFleetSection.tsx | 4 ++++ 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 20ac085abd..402502ef97 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -32,7 +32,6 @@ import { useRepositoryIntegration, useUserRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; -import { FleetFindingsCallout } from "@posthog/ui/features/scouts/components/FleetFindingsCallout"; import { ScoutsFleetSection } from "@posthog/ui/features/scouts/components/ScoutsFleetSection"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; @@ -149,7 +148,6 @@ export function ConfigureAgentsSection() { } > -
{" · latest "} - + ) : null} - + ); } 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. */} From 6610ccc91500cfe6d7d989b29a025451143f9fee Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Tue, 30 Jun 2026 22:01:22 +0100 Subject: [PATCH 3/3] refactor(scouts): address review feedback on findings - Stale-data warning also fires on a background runs-refetch failure (runsError), not just emissions errors. - Drop superfluous .slice() before .sort() in mostRecentEmittedRuns and filterAndSortScoutFindings (.filter() already returns a fresh array). - Parameterise the sort tests with it.each and add 'oldest' coverage. --- .../core/src/scouts/scoutFindings.test.ts | 39 ++++++++++--------- packages/core/src/scouts/scoutFindings.ts | 3 +- .../scouts/components/ScoutFindingsView.tsx | 2 +- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/packages/core/src/scouts/scoutFindings.test.ts b/packages/core/src/scouts/scoutFindings.test.ts index 3d7ae45cd8..425662e462 100644 --- a/packages/core/src/scouts/scoutFindings.test.ts +++ b/packages/core/src/scouts/scoutFindings.test.ts @@ -13,6 +13,7 @@ import { SCOUT_FINDINGS_SCOUT_FILTER_ALL, SCOUT_FINDINGS_SEVERITY_FILTER_ALL, type ScoutFindingsFilter, + type ScoutFindingsSortKey, summarizeEmittedRuns, summarizeScoutFindingRows, } from "./scoutFindings"; @@ -163,27 +164,29 @@ describe("filterAndSortScoutFindings", () => { [], ); - it("sorts newest first by default", () => { + 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).map((r) => r.emission.id), - ).toEqual(["b", "a"]); - }); - - it("sorts by severity (most severe first)", () => { - expect( - filterAndSortScoutFindings(rows, { ...ALL_FILTER, sort: "severity" }).map( + filterAndSortScoutFindings(rows, { ...ALL_FILTER, sort }).map( (r) => r.emission.id, ), - ).toEqual(["a", "b"]); - }); - - it("sorts by confidence (highest first)", () => { - expect( - filterAndSortScoutFindings(rows, { - ...ALL_FILTER, - sort: "confidence", - }).map((r) => r.emission.id), - ).toEqual(["b", "a"]); + ).toEqual(expected); }); it("filters by scout", () => { diff --git a/packages/core/src/scouts/scoutFindings.ts b/packages/core/src/scouts/scoutFindings.ts index b5dd4e9543..1def6920c6 100644 --- a/packages/core/src/scouts/scoutFindings.ts +++ b/packages/core/src/scouts/scoutFindings.ts @@ -91,7 +91,6 @@ function severityRank(severity: string | null): number { export function mostRecentEmittedRuns(runs: ScoutRun[]): ScoutRun[] { return runs .filter((run) => (run.emitted_count ?? 0) > 0) - .slice() .sort((a, b) => (b.completed_at ?? b.started_at ?? "").localeCompare( a.completed_at ?? a.started_at ?? "", @@ -195,7 +194,7 @@ export function filterAndSortScoutFindings( } return true; }); - return filtered.slice().sort((a, b) => { + return filtered.sort((a, b) => { if (filter.sort === "oldest") return -byNewest(a, b); if (filter.sort === "severity") { const diff = diff --git a/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx index 353de253bb..92e36f4924 100644 --- a/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutFindingsView.tsx @@ -249,7 +249,7 @@ export function ScoutFindingsView() { {hasLoadedOnce && - emissionsError && + (emissionsError || runsError) && emissionsFetching === false && rows.length > 0 ? ( // A later poll/retry failed while a prior set is still on screen.