From c1a2bc4725d0308bf900346f222c06ac35db2bef Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 14:20:15 +0100 Subject: [PATCH 01/37] feat(scouts): scouts control pane in the agents area Adds a Scouts surface under /code/agents/scouts backed by the PostHog Cloud scout endpoints (/api/projects/{id}/signals/scout/): - api-client: ScoutConfig/ScoutRun/ScoutEmission/ScoutScratchpadEntry types and list/get/patch methods for configs, runs, emissions, scratchpad - core/scouts: pure presentation helpers (status normalization, timeout inference, stuck-run detection, run filters, fleet/per-scout rollups, interval formatting) with unit tests - ui/features/scouts: fleet view with inline config controls (on/off, live vs dry-run, cadence), per-scout run history with emitted/quiet/failed filter chips and summary previews, run detail with emissions, memory written, and an external task-log link - agents config page gains a Scouts entry card (above Responders); /code/agents becomes a layout route with nested scout routes Known API gaps are patched client-side and marked with comments: no skill_name filter on runs (client-side filter over the recent window), no failure kind (inferred from ~30m duration), no aggregate stats (windowed client rollups), canonical-vs-custom via hardcoded skill list, per-run memory limited to created entries. --- packages/api-client/src/posthog-client.ts | 167 ++++++++++ .../core/src/scouts/scoutPresentation.test.ts | 245 ++++++++++++++ packages/core/src/scouts/scoutPresentation.ts | 269 ++++++++++++++++ packages/ui/src/features/inbox/CLAUDE.md | 2 +- .../components/ConfigureAgentsSection.tsx | 8 + .../scouts/components/ScoutBadges.tsx | 90 ++++++ .../scouts/components/ScoutConfigControls.tsx | 84 +++++ .../scouts/components/ScoutDetailView.tsx | 300 ++++++++++++++++++ .../scouts/components/ScoutEmissionCard.tsx | 40 +++ .../scouts/components/ScoutRunDetailView.tsx | 292 +++++++++++++++++ .../scouts/components/ScoutsSummaryCard.tsx | 63 ++++ .../features/scouts/components/ScoutsView.tsx | 277 ++++++++++++++++ .../features/scouts/hooks/scoutQueryKeys.ts | 11 + .../scouts/hooks/useScoutConfigMutations.ts | 56 ++++ .../features/scouts/hooks/useScoutConfigs.ts | 14 + .../src/features/scouts/hooks/useScoutRun.ts | 23 ++ .../scouts/hooks/useScoutRunEmissions.ts | 22 ++ .../src/features/scouts/hooks/useScoutRuns.ts | 26 ++ .../scouts/hooks/useScoutScratchpad.ts | 22 ++ packages/ui/src/router/routeTree.gen.ts | 167 +++++++++- packages/ui/src/router/routes/code/agents.tsx | 5 +- .../src/router/routes/code/agents/index.tsx | 6 + .../code/agents/scouts.$skillName.index.tsx | 11 + .../agents/scouts.$skillName.runs.$runId.tsx | 13 + .../routes/code/agents/scouts.$skillName.tsx | 5 + .../routes/code/agents/scouts.index.tsx | 6 + .../src/router/routes/code/agents/scouts.tsx | 5 + 27 files changed, 2219 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/scouts/scoutPresentation.test.ts create mode 100644 packages/core/src/scouts/scoutPresentation.ts create mode 100644 packages/ui/src/features/scouts/components/ScoutBadges.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutConfigControls.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutDetailView.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutsSummaryCard.tsx create mode 100644 packages/ui/src/features/scouts/components/ScoutsView.tsx create mode 100644 packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutConfigs.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutRun.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutRuns.ts create mode 100644 packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts create mode 100644 packages/ui/src/router/routes/code/agents/index.tsx create mode 100644 packages/ui/src/router/routes/code/agents/scouts.$skillName.index.tsx create mode 100644 packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx create mode 100644 packages/ui/src/router/routes/code/agents/scouts.$skillName.tsx create mode 100644 packages/ui/src/router/routes/code/agents/scouts.index.tsx create mode 100644 packages/ui/src/router/routes/code/agents/scouts.tsx diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index a5a01c842..85e127cba 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -171,6 +171,67 @@ export interface SignalSourceConfig { status: "running" | "completed" | "failed" | null; } +// ── Signals scouts ─────────────────────────────────────────────────────────── +// Backend: posthog `products/signals/backend/scout_harness/views.py`. +// Endpoints live under /api/projects/{id}/signals/scout/ and require the +// `signal_scout:read` / `signal_scout:write` scopes. + +export interface ScoutConfig { + id: string; + skill_name: string; + enabled: boolean; + /** False means dry-run: the scout runs but findings are not emitted. */ + emit: boolean; + run_interval_minutes: number; + last_run_at: string | null; + created_at: string; +} + +export interface ScoutRun { + run_id: string; + skill_name: string; + skill_version: number; + /** TaskRun-derived status, e.g. "completed" | "failed" | "in_progress" | "queued". */ + status: string; + started_at: string | null; + completed_at: string | null; + task_id: string | null; + task_run_id: string | null; + /** Relative PostHog cloud path to the backing task run. */ + task_url: string | null; + summary: string; + emitted_count: number | null; + emitted_finding_ids: string[]; +} + +export interface ScoutEmission { + id: string; + run_id: string; + finding_id: string; + description: string; + weight: number; + confidence: number; + severity: string | null; + source_id: string; + emitted_at: string; +} + +export interface ScoutScratchpadEntry { + key: string; + content: string; + created_at: string; + updated_at: string; + created_by_run_id: string | null; +} + +export interface ScoutRunsQueryParams { + date_from?: string; + date_to?: string; + text?: string; + emitted?: boolean; + limit?: number; +} + export interface ExternalDataSourceSchema { id: string; name: string; @@ -1047,6 +1108,112 @@ export class PostHogAPIClient { return (await response.json()) as SignalSourceConfig; } + private async scoutGet( + projectId: number, + subPath: string, + query?: Record, + ): Promise { + const urlPath = `/api/projects/${projectId}/signals/scout/${subPath}`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) url.searchParams.set(key, String(value)); + } + const response = await this.api.fetcher.fetch({ + method: "get", + url, + path: urlPath, + }); + if (!response.ok) { + throw new Error( + `Scout request failed (${subPath}): ${response.statusText}`, + ); + } + return (await response.json()) as T; + } + + async listScoutConfigs(projectId: number): Promise { + const data = await this.scoutGet< + { results: ScoutConfig[] } | ScoutConfig[] + >(projectId, "configs/"); + return Array.isArray(data) ? data : (data.results ?? []); + } + + async updateScoutConfig( + projectId: number, + configId: string, + updates: { + enabled?: boolean; + emit?: boolean; + run_interval_minutes?: number; + }, + ): Promise { + const urlPath = `/api/projects/${projectId}/signals/scout/configs/${configId}/`; + const url = new URL(`${this.api.baseUrl}${urlPath}`); + const response = await this.api.fetcher.fetch({ + method: "patch", + url, + path: urlPath, + overrides: { + body: JSON.stringify(updates), + }, + }); + if (!response.ok) { + const errorData = (await response.json().catch(() => ({}))) as { + detail?: string; + }; + throw new Error( + errorData.detail ?? + `Failed to update scout config: ${response.statusText}`, + ); + } + return (await response.json()) as ScoutConfig; + } + + async listScoutRuns( + projectId: number, + params?: ScoutRunsQueryParams, + ): Promise { + const data = await this.scoutGet<{ results: ScoutRun[] } | ScoutRun[]>( + projectId, + "runs/", + { + date_from: params?.date_from, + date_to: params?.date_to, + text: params?.text, + emitted: params?.emitted, + limit: params?.limit, + }, + ); + return Array.isArray(data) ? data : (data.results ?? []); + } + + async getScoutRun(projectId: number, runId: string): Promise { + return await this.scoutGet(projectId, `runs/${runId}/`); + } + + async listScoutRunEmissions( + projectId: number, + runId: string, + ): Promise { + const data = await this.scoutGet< + { results: ScoutEmission[] } | ScoutEmission[] + >(projectId, `runs/${runId}/emissions/`); + return Array.isArray(data) ? data : (data.results ?? []); + } + + async searchScoutScratchpad( + projectId: number, + params?: { text?: string; limit?: number }, + ): Promise { + const data = await this.scoutGet< + { results: ScoutScratchpadEntry[] } | ScoutScratchpadEntry[] + >(projectId, "scratchpad/", { + text: params?.text, + limit: params?.limit, + }); + return Array.isArray(data) ? data : (data.results ?? []); + } + async listEvaluations(projectId: number): Promise { const data = await this.api.get( "/api/environments/{project_id}/evaluations/", diff --git a/packages/core/src/scouts/scoutPresentation.test.ts b/packages/core/src/scouts/scoutPresentation.test.ts new file mode 100644 index 000000000..81a489898 --- /dev/null +++ b/packages/core/src/scouts/scoutPresentation.test.ts @@ -0,0 +1,245 @@ +import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; +import { describe, expect, it } from "vitest"; +import { + computeFleetSummary, + computeScoutRollups, + deriveRunFailureKind, + formatRunDuration, + formatRunInterval, + formatRunIntervalShort, + getScoutOrigin, + isRunStuck, + normalizeRunStatus, + prettifyScoutSkillName, + runDurationSeconds, + runMatchesFilter, + scoutSkillNameFromSlug, + scoutSkillSlug, + sortConfigsForDisplay, +} from "./scoutPresentation"; + +const NOW = new Date("2026-06-10T12:00:00Z"); + +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 nothing.", + emitted_count: 0, + emitted_finding_ids: [], + ...overrides, + }; +} + +function makeConfig(overrides: Partial = {}): ScoutConfig { + return { + id: "config-1", + skill_name: "signals-scout-error-tracking", + enabled: true, + emit: true, + run_interval_minutes: 60, + last_run_at: "2026-06-10T11:00:00Z", + created_at: "2026-06-01T00:00:00Z", + ...overrides, + }; +} + +describe("naming", () => { + it("prettifies skill names", () => { + expect(prettifyScoutSkillName("signals-scout-error-tracking")).toBe( + "Error tracking", + ); + expect(prettifyScoutSkillName("signals-scout-ai-observability")).toBe( + "Ai observability", + ); + expect(prettifyScoutSkillName("custom_thing")).toBe("Custom thing"); + }); + + it("round-trips slugs", () => { + expect(scoutSkillSlug("signals-scout-error-tracking")).toBe( + "error-tracking", + ); + expect(scoutSkillNameFromSlug("error-tracking")).toBe( + "signals-scout-error-tracking", + ); + expect(scoutSkillNameFromSlug("signals-scout-error-tracking")).toBe( + "signals-scout-error-tracking", + ); + }); + + it("classifies canonical vs custom scouts", () => { + expect(getScoutOrigin("signals-scout-error-tracking")).toBe("canonical"); + expect(getScoutOrigin("signals-scout-react-performance")).toBe("custom"); + }); +}); + +describe("run status", () => { + it("normalizes TaskRun statuses case-insensitively", () => { + expect(normalizeRunStatus("COMPLETED")).toBe("completed"); + expect(normalizeRunStatus("failed")).toBe("failed"); + expect(normalizeRunStatus("IN_PROGRESS")).toBe("running"); + expect(normalizeRunStatus("queued")).toBe("queued"); + expect(normalizeRunStatus("something-else")).toBe("unknown"); + }); + + it("computes duration, falling back to now for unfinished runs", () => { + expect(runDurationSeconds(makeRun(), NOW)).toBe(120); + const running = makeRun({ + status: "in_progress", + started_at: "2026-06-10T11:58:00Z", + completed_at: null, + }); + expect(runDurationSeconds(running, NOW)).toBe(120); + expect(runDurationSeconds(makeRun({ started_at: null }), NOW)).toBeNull(); + }); + + it("formats durations", () => { + expect(formatRunDuration(42)).toBe("42s"); + expect(formatRunDuration(134)).toBe("2m 14s"); + expect(formatRunDuration(3 * 3600)).toBe("3h"); + expect(formatRunDuration(null)).toBe(""); + }); + + it("classifies long failed runs as timeouts", () => { + const timedOut = makeRun({ + status: "failed", + started_at: "2026-06-10T11:00:00Z", + completed_at: "2026-06-10T11:30:10Z", + summary: "", + }); + expect(deriveRunFailureKind(timedOut, NOW)).toBe("timed_out"); + const errored = makeRun({ + status: "failed", + completed_at: "2026-06-10T11:00:30Z", + }); + expect(deriveRunFailureKind(errored, NOW)).toBe("error"); + expect(deriveRunFailureKind(makeRun(), NOW)).toBeNull(); + }); + + it("flags in-progress runs past the deadline as stuck", () => { + const stuck = makeRun({ + status: "in_progress", + started_at: "2026-06-10T11:20:00Z", + completed_at: null, + }); + expect(isRunStuck(stuck, NOW)).toBe(true); + const fresh = makeRun({ + status: "in_progress", + started_at: "2026-06-10T11:55:00Z", + completed_at: null, + }); + expect(isRunStuck(fresh, NOW)).toBe(false); + expect(isRunStuck(makeRun(), NOW)).toBe(false); + }); +}); + +describe("run filters", () => { + const emitted = makeRun({ emitted_count: 2 }); + const quiet = makeRun({ emitted_count: 0 }); + const failed = makeRun({ status: "failed", emitted_count: 0 }); + + it("matches runs to filter chips", () => { + expect(runMatchesFilter(emitted, "emitted")).toBe(true); + expect(runMatchesFilter(quiet, "emitted")).toBe(false); + expect(runMatchesFilter(quiet, "quiet")).toBe(true); + expect(runMatchesFilter(failed, "quiet")).toBe(false); + expect(runMatchesFilter(failed, "failed")).toBe(true); + expect(runMatchesFilter(emitted, "all")).toBe(true); + }); +}); + +describe("rollups", () => { + it("aggregates per-scout counts and tracks latest/running runs", () => { + const runs = [ + makeRun({ run_id: "a", started_at: "2026-06-10T10:00:00Z" }), + makeRun({ + run_id: "b", + started_at: "2026-06-10T11:00:00Z", + emitted_count: 2, + }), + makeRun({ + run_id: "c", + status: "failed", + started_at: "2026-06-10T09:00:00Z", + }), + makeRun({ + run_id: "d", + skill_name: "signals-scout-logs", + status: "in_progress", + started_at: "2026-06-10T11:58:00Z", + completed_at: null, + }), + ]; + const rollups = computeScoutRollups(runs); + const errorTracking = rollups.get("signals-scout-error-tracking"); + expect(errorTracking).toMatchObject({ + runCount: 3, + completedCount: 2, + failedCount: 1, + emittedCount: 2, + }); + expect(errorTracking?.latestRun?.run_id).toBe("b"); + expect(errorTracking?.runningRun).toBeNull(); + expect(rollups.get("signals-scout-logs")?.runningRun?.run_id).toBe("d"); + }); + + it("computes the fleet summary", () => { + const configs = [ + makeConfig(), + makeConfig({ + id: "config-2", + skill_name: "signals-scout-logs", + enabled: false, + }), + ]; + const rollups = computeScoutRollups([ + makeRun({ emitted_count: 2 }), + makeRun({ run_id: "x", status: "failed" }), + ]); + const summary = computeFleetSummary(configs, rollups); + expect(summary).toMatchObject({ + totalCount: 2, + enabledCount: 1, + runningCount: 0, + emittedCount: 2, + }); + expect(summary.successRate).toBe(0.5); + }); + + it("returns a null success rate with no finished runs", () => { + const summary = computeFleetSummary([], computeScoutRollups([])); + expect(summary.successRate).toBeNull(); + }); +}); + +describe("intervals and ordering", () => { + it("formats intervals", () => { + expect(formatRunInterval(60)).toBe("Hourly"); + expect(formatRunInterval(90)).toBe("Every 90 minutes"); + expect(formatRunInterval(2880)).toBe("Every 2 days"); + expect(formatRunIntervalShort(60)).toBe("hourly"); + expect(formatRunIntervalShort(180)).toBe("every 3h"); + }); + + it("sorts enabled scouts first, then alphabetically", () => { + const configs = [ + makeConfig({ skill_name: "signals-scout-logs", enabled: false }), + makeConfig({ skill_name: "signals-scout-surveys" }), + makeConfig({ skill_name: "signals-scout-error-tracking" }), + ]; + expect( + sortConfigsForDisplay(configs).map((config) => config.skill_name), + ).toEqual([ + "signals-scout-error-tracking", + "signals-scout-surveys", + "signals-scout-logs", + ]); + }); +}); diff --git a/packages/core/src/scouts/scoutPresentation.ts b/packages/core/src/scouts/scoutPresentation.ts new file mode 100644 index 000000000..7ee405e3b --- /dev/null +++ b/packages/core/src/scouts/scoutPresentation.ts @@ -0,0 +1,269 @@ +import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; + +/** + * Canonical scouts shipped in the PostHog repo (products/signals/skills). + * The configs endpoint does not yet distinguish canonical from hand-authored + * skills (scouts-ui api gap 4); until it carries `seeded_by`, classify by + * this known-name list. + */ +export const CANONICAL_SCOUT_SKILLS = new Set([ + "signals-scout-general", + "signals-scout-anomaly-detection", + "signals-scout-ai-observability", + "signals-scout-csp-violations", + "signals-scout-data-pipelines", + "signals-scout-error-tracking", + "signals-scout-experiments", + "signals-scout-feature-flags", + "signals-scout-health-checks", + "signals-scout-logs", + "signals-scout-observability-gaps", + "signals-scout-revenue-analytics", + "signals-scout-session-replay", + "signals-scout-surveys", + "signals-scout-web-analytics", +]); + +export type ScoutOrigin = "canonical" | "custom"; + +export function getScoutOrigin(skillName: string): ScoutOrigin { + return CANONICAL_SCOUT_SKILLS.has(skillName) ? "canonical" : "custom"; +} + +/** "signals-scout-error-tracking" → "Error tracking" */ +export function prettifyScoutSkillName(skillName: string): string { + const cleaned = skillName + .replace(/^signals-scout-/, "") + .replace(/[-_]/g, " ") + .trim(); + if (!cleaned) return skillName; + return cleaned.charAt(0).toUpperCase() + cleaned.slice(1); +} + +/** Route param form: "signals-scout-error-tracking" → "error-tracking" */ +export function scoutSkillSlug(skillName: string): string { + return skillName.replace(/^signals-scout-/, ""); +} + +export function scoutSkillNameFromSlug(slug: string): string { + return slug.startsWith("signals-scout-") ? slug : `signals-scout-${slug}`; +} + +export type ScoutRunStatus = + | "completed" + | "failed" + | "running" + | "queued" + | "unknown"; + +export function normalizeRunStatus(status: string): ScoutRunStatus { + const lower = status.toLowerCase(); + if (lower === "completed") return "completed"; + if (lower === "failed" || lower === "cancelled") return "failed"; + if (lower === "in_progress") return "running"; + if (lower === "queued" || lower === "not_started") return "queued"; + return "unknown"; +} + +export function runDurationSeconds(run: ScoutRun, now: Date): number | null { + if (!run.started_at) return null; + const started = new Date(run.started_at).getTime(); + if (Number.isNaN(started)) return null; + const ended = run.completed_at + ? new Date(run.completed_at).getTime() + : now.getTime(); + if (Number.isNaN(ended) || ended < started) return null; + return (ended - started) / 1000; +} + +export function formatRunDuration(seconds: number | null): string { + if (seconds === null) return ""; + if (seconds < 60) return `${Math.round(seconds)}s`; + const mins = Math.floor(seconds / 60); + const secs = Math.round(seconds % 60); + if (mins < 60) return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; + const hours = Math.floor(mins / 60); + const remainMins = mins % 60; + return remainMins > 0 ? `${hours}h ${remainMins}m` : `${hours}h`; +} + +/** + * Scout runs are hard-killed at the ~31-minute Temporal activity deadline and + * surface as bare "failed" with an empty summary and no error field (scouts-ui + * api gap 2). Until the serializer carries a failure kind, infer a timeout + * from the run length. + */ +const TIMEOUT_THRESHOLD_SECONDS = 29 * 60; + +export type ScoutRunFailureKind = "timed_out" | "error"; + +export function deriveRunFailureKind( + run: ScoutRun, + now: Date, +): ScoutRunFailureKind | null { + if (normalizeRunStatus(run.status) !== "failed") return null; + const duration = runDurationSeconds(run, now); + if (duration !== null && duration >= TIMEOUT_THRESHOLD_SECONDS) { + return "timed_out"; + } + return "error"; +} + +/** + * A SIGKILL mid-run can strand a TaskRun in IN_PROGRESS with no self-heal + * (posthog scouts dogfooding issue 09). Past the run deadline we can be sure + * the run is not actually still working. + */ +const STUCK_THRESHOLD_SECONDS = 35 * 60; + +export function isRunStuck(run: ScoutRun, now: Date): boolean { + if (normalizeRunStatus(run.status) !== "running") return false; + const duration = runDurationSeconds(run, now); + return duration !== null && duration >= STUCK_THRESHOLD_SECONDS; +} + +export type ScoutRunFilter = "all" | "emitted" | "quiet" | "failed"; + +export function runMatchesFilter( + run: ScoutRun, + filter: ScoutRunFilter, +): boolean { + const status = normalizeRunStatus(run.status); + switch (filter) { + case "all": + return true; + case "emitted": + return (run.emitted_count ?? 0) > 0; + case "quiet": + return status === "completed" && (run.emitted_count ?? 0) === 0; + case "failed": + return status === "failed"; + } +} + +export interface ScoutRollup { + runCount: number; + completedCount: number; + failedCount: number; + emittedCount: number; + latestRun: ScoutRun | null; + runningRun: ScoutRun | null; +} + +function emptyRollup(): ScoutRollup { + return { + runCount: 0, + completedCount: 0, + failedCount: 0, + emittedCount: 0, + latestRun: null, + runningRun: null, + }; +} + +/** + * Client-side rollup over the most recent fleet runs. The runs endpoint has + * no per-scout filter or aggregate stats yet (scouts-ui api gaps 1 and 3) and + * caps at 100 rows, so these numbers describe "the recent window we can see", + * not all time. Surface them with that framing. + */ +export function computeScoutRollups( + runs: ScoutRun[], +): Map { + const rollups = new Map(); + for (const run of runs) { + let rollup = rollups.get(run.skill_name); + if (!rollup) { + rollup = emptyRollup(); + rollups.set(run.skill_name, rollup); + } + rollup.runCount += 1; + const status = normalizeRunStatus(run.status); + if (status === "completed") rollup.completedCount += 1; + if (status === "failed") rollup.failedCount += 1; + rollup.emittedCount += run.emitted_count ?? 0; + const startedAt = run.started_at ? new Date(run.started_at).getTime() : 0; + const latestStartedAt = rollup.latestRun?.started_at + ? new Date(rollup.latestRun.started_at).getTime() + : -1; + if (startedAt > latestStartedAt) rollup.latestRun = run; + if (status === "running" && !rollup.runningRun) rollup.runningRun = run; + } + return rollups; +} + +export interface FleetSummary { + totalCount: number; + enabledCount: number; + runningCount: number; + emittedCount: number; + /** Completed / (completed + failed) over the visible window, or null when no finished runs. */ + successRate: number | null; +} + +export function computeFleetSummary( + configs: ScoutConfig[], + rollups: Map, +): FleetSummary { + let runningCount = 0; + let emittedCount = 0; + let completedCount = 0; + let failedCount = 0; + for (const rollup of rollups.values()) { + if (rollup.runningRun) runningCount += 1; + emittedCount += rollup.emittedCount; + completedCount += rollup.completedCount; + failedCount += rollup.failedCount; + } + const finished = completedCount + failedCount; + return { + totalCount: configs.length, + enabledCount: configs.filter((config) => config.enabled).length, + runningCount, + emittedCount, + successRate: finished > 0 ? completedCount / finished : null, + }; +} + +export interface RunIntervalOption { + minutes: number; + label: string; +} + +export const RUN_INTERVAL_OPTIONS: RunIntervalOption[] = [ + { minutes: 30, label: "Every 30 minutes" }, + { minutes: 60, label: "Hourly" }, + { minutes: 120, label: "Every 2 hours" }, + { minutes: 180, label: "Every 3 hours" }, + { minutes: 360, label: "Every 6 hours" }, + { minutes: 720, label: "Every 12 hours" }, + { minutes: 1440, label: "Daily" }, +]; + +export function formatRunInterval(minutes: number): string { + const preset = RUN_INTERVAL_OPTIONS.find( + (option) => option.minutes === minutes, + ); + if (preset) return preset.label; + if (minutes % 1440 === 0) return `Every ${minutes / 1440} days`; + if (minutes % 60 === 0) return `Every ${minutes / 60} hours`; + return `Every ${minutes} minutes`; +} + +/** Short form for row badges: "hourly", "every 3h". */ +export function formatRunIntervalShort(minutes: number): string { + if (minutes === 60) return "hourly"; + if (minutes === 1440) return "daily"; + if (minutes % 1440 === 0) return `every ${minutes / 1440}d`; + if (minutes % 60 === 0) return `every ${minutes / 60}h`; + return `every ${minutes}m`; +} + +export function sortConfigsForDisplay(configs: ScoutConfig[]): ScoutConfig[] { + return [...configs].sort((a, b) => { + if (a.enabled !== b.enabled) return a.enabled ? -1 : 1; + return prettifyScoutSkillName(a.skill_name).localeCompare( + prettifyScoutSkillName(b.skill_name), + ); + }); +} diff --git a/packages/ui/src/features/inbox/CLAUDE.md b/packages/ui/src/features/inbox/CLAUDE.md index 732af2ad3..0f7cbefd1 100644 --- a/packages/ui/src/features/inbox/CLAUDE.md +++ b/packages/ui/src/features/inbox/CLAUDE.md @@ -106,7 +106,7 @@ When adding or changing UI, reuse those primitives first. Avoid encoding one-off - Do not reuse the deleted legacy `ReportListRow`, `ReportDetailPane`, or old list/detail stores. - Do not put page-level Inbox title or navigation into the global app header; `InboxView` owns the Inbox page chrome. - Do not add a configure shortcut back into the Inbox header; Responders configuration is a sidebar destination. -- Do not add Scout UI until a corresponding backend exists in this repo. +- Scout UI lives in `features/scouts/` and is backed by the PostHog Cloud scout endpoints (`/api/projects/{teamId}/signals/scout/`). Do not add scout controls that have no backing endpoint there. - Do not put preview shims or mock report data in `apps/code/index.html`; the app shell should stay minimal. - Do not call `electronTRPC` directly from Inbox code. Use the existing API client, React Query hooks, or tRPC client wrappers. - Do not preserve compatibility with unshipped intermediate UI shapes on this branch. Replace them cleanly. diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 4f1f12f40..75e503e40 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -27,6 +27,7 @@ import { useRepositoryIntegration, useUserRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; +import { ScoutsSummaryCard } from "@posthog/ui/features/scouts/components/ScoutsSummaryCard"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; @@ -105,6 +106,13 @@ export function ConfigureAgentsSection() { /> + + + + = { + ok: "bg-(--green-9)", + running: "bg-(--blue-9) animate-pulse", + failing: "bg-(--amber-9)", + stuck: "bg-(--red-9)", + disabled: "bg-(--gray-7)", +}; + +export function ScoutStatusDot({ state }: { state: ScoutRowState }) { + return ( + + ); +} + +export function ScoutOriginBadge({ skillName }: { skillName: string }) { + const origin = getScoutOrigin(skillName); + return ( + + {origin === "canonical" ? "Canonical" : "Custom"} + + ); +} + +export function DryRunBadge({ config }: { config: ScoutConfig }) { + if (config.emit) return null; + return ( + + Dry run + + ); +} + +const SEVERITY_COLORS: Record = { + P0: "red", + P1: "red", + P2: "orange", + P3: "amber", + P4: "gray", +}; + +export function SeverityBadge({ severity }: { severity: string | null }) { + if (!severity) return null; + return ( + + {severity} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx new file mode 100644 index 000000000..11d429462 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx @@ -0,0 +1,84 @@ +import type { ScoutConfig } from "@posthog/api-client/posthog-client"; +import { + formatRunInterval, + RUN_INTERVAL_OPTIONS, +} from "@posthog/core/scouts/scoutPresentation"; +import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; +import { Flex, Switch, Tooltip } from "@radix-ui/themes"; +import { useMemo } from "react"; +import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; + +const MODE_OPTIONS = [ + { value: "live", label: "Live" }, + { value: "dry_run", label: "Dry run" }, +]; + +interface ScoutConfigControlsProps { + config: ScoutConfig; + onUpdate: (configId: string, updates: ScoutConfigUpdate) => void; +} + +/** + * The three per-scout controls: live vs dry-run, cadence, and on/off. + * Used inline on fleet rows and in the scout detail header so config never + * requires leaving the current view. + */ +export function ScoutConfigControls({ + config, + onUpdate, +}: ScoutConfigControlsProps) { + const intervalOptions = useMemo(() => { + const options = RUN_INTERVAL_OPTIONS.map((option) => ({ + value: String(option.minutes), + label: option.label, + })); + if ( + !RUN_INTERVAL_OPTIONS.some( + (option) => option.minutes === config.run_interval_minutes, + ) + ) { + options.push({ + value: String(config.run_interval_minutes), + label: formatRunInterval(config.run_interval_minutes), + }); + } + return options; + }, [config.run_interval_minutes]); + + return ( + + + + + onUpdate(config.id, { emit: value === "live" }) + } + /> + + + + onUpdate(config.id, { run_interval_minutes: Number(value) }) + } + /> + + + onUpdate(config.id, { enabled: checked }) + } + aria-label={`${config.skill_name} enabled`} + /> + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx new file mode 100644 index 000000000..2c2c5a046 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -0,0 +1,300 @@ +import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { + computeScoutRollups, + deriveRunFailureKind, + formatRunDuration, + normalizeRunStatus, + prettifyScoutSkillName, + runDurationSeconds, + runMatchesFilter, + type ScoutRunFilter, + scoutSkillNameFromSlug, + scoutSkillSlug, +} from "@posthog/core/scouts/scoutPresentation"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; +import { useScoutConfigs } from "../hooks/useScoutConfigs"; +import { useScoutRuns } from "../hooks/useScoutRuns"; +import { + DryRunBadge, + deriveScoutRowState, + ScoutOriginBadge, + ScoutStatusDot, +} from "./ScoutBadges"; +import { ScoutConfigControls } from "./ScoutConfigControls"; + +const FILTERS: { value: ScoutRunFilter; label: string }[] = [ + { value: "all", label: "All" }, + { value: "emitted", label: "Emitted" }, + { value: "quiet", label: "Quiet" }, + { value: "failed", label: "Failed" }, +]; + +export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { + const skillName = scoutSkillNameFromSlug(skillSlug); + const displayName = prettifyScoutSkillName(skillName); + + const headerContent = useMemo( + () => ( + + + + {displayName} + + + ), + [displayName], + ); + useSetHeaderContent(headerContent); + + const { data: configs } = useScoutConfigs(); + const { data: runs, isLoading: runsLoading } = useScoutRuns(); + const { updateConfig } = useScoutConfigMutations(); + const [filter, setFilter] = useState("all"); + + const config = configs?.find((entry) => entry.skill_name === skillName); + // The runs endpoint has no skill_name filter yet (scouts-ui api gap 1), so + // select this scout's runs from the recent fleet window client-side. + const scoutRuns = useMemo( + () => (runs ?? []).filter((run) => run.skill_name === skillName), + [runs, skillName], + ); + const rollup = useMemo( + () => computeScoutRollups(scoutRuns).get(skillName), + [scoutRuns, skillName], + ); + const filteredRuns = useMemo( + () => scoutRuns.filter((run) => runMatchesFilter(run, filter)), + [scoutRuns, filter], + ); + const filterCounts = useMemo(() => { + const counts = new Map(); + for (const entry of FILTERS) { + counts.set( + entry.value, + scoutRuns.filter((run) => runMatchesFilter(run, entry.value)).length, + ); + } + return counts; + }, [scoutRuns]); + + const latestVersion = scoutRuns[0]?.skill_version; + const state = config + ? deriveScoutRowState(config, rollup, new Date()) + : "disabled"; + + return ( + + + + + Scouts + + + + + {displayName} + + + {config ? : null} + {latestVersion !== undefined ? ( + v{latestVersion} + ) : null} + + {skillName} + + +
+
+ + {config ? ( + + + + Configuration + + + {config.last_run_at ? ( + <> + Last dispatched{" "} + + + ) : ( + "Never dispatched" + )} + + + + + ) : ( + + No config found for this scout on the current project. + + )} + + {rollup && rollup.runCount > 0 ? ( + + Recent window: {rollup.runCount} runs · {rollup.completedCount}{" "} + completed · {rollup.failedCount} failed · {rollup.emittedCount}{" "} + signal + {rollup.emittedCount === 1 ? "" : "s"} emitted + + ) : null} + + + + + Runs + + + {FILTERS.map((entry) => ( + + ))} + + + {runsLoading ? ( + + ) : filteredRuns.length === 0 ? ( + + {scoutRuns.length === 0 + ? "No runs in the recent window the API returns." + : "No runs match this filter in the recent window."} + + ) : ( + + {filteredRuns.map((run) => ( + + ))} + + )} + + + Showing this scout's runs from the most recent fleet runs + the API returns (currently capped at 100 fleet-wide). + + + +
+
+
+ ); +} + +function ScoutRunListItem({ + run, + skillSlug, +}: { + run: ScoutRun; + skillSlug: string; +}) { + const now = new Date(); + const status = normalizeRunStatus(run.status); + const failureKind = deriveRunFailureKind(run, now); + const duration = formatRunDuration(runDurationSeconds(run, now)); + const emitted = run.emitted_count ?? 0; + + return ( + + + + + + {duration ? ( + · {duration} + ) : null} + {failureKind ? ( + + · {failureKind === "timed_out" ? "timed out" : "failed"} + + ) : null} + + {emitted > 0 ? ( + + {emitted} signal{emitted === 1 ? "" : "s"} + + ) : status === "completed" ? ( + quiet + ) : null} + + {run.summary ? ( + + {run.summary} + + ) : status === "failed" ? ( + + No summary – the run ended before writing its close-out. Open the + run for the task log. + + ) : null} + + + ); +} + +function RunGlyph({ status, emitted }: { status: string; emitted: number }) { + if (status === "failed") { + return ; + } + if (status === "running" || status === "queued") { + return ( + + ); + } + if (emitted > 0) { + return ; + } + return ·; +} + +function RunListSkeleton() { + return ( + + {[0, 1, 2].map((index) => ( + + ))} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx new file mode 100644 index 000000000..9aa6a4361 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx @@ -0,0 +1,40 @@ +import { CompassIcon } from "@phosphor-icons/react"; +import type { ScoutEmission } from "@posthog/api-client/posthog-client"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { SeverityBadge } from "./ScoutBadges"; + +export function ScoutEmissionCard({ emission }: { emission: ScoutEmission }) { + return ( + + + + Finding + + + confidence {Math.round(emission.confidence * 100)}% + + + + + + + + + {emission.finding_id} + + + Sent to the signals pipeline – report assignment isn't traceable + here yet + + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx new file mode 100644 index 000000000..8ef55aeb0 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx @@ -0,0 +1,292 @@ +import { + ArrowLeftIcon, + ArrowSquareOutIcon, + BrainIcon, + CompassIcon, + TerminalIcon, +} from "@phosphor-icons/react"; +import type { ScoutScratchpadEntry } from "@posthog/api-client/posthog-client"; +import { + deriveRunFailureKind, + formatRunDuration, + normalizeRunStatus, + prettifyScoutSkillName, + runDurationSeconds, + scoutSkillNameFromSlug, +} from "@posthog/core/scouts/scoutPresentation"; +import { getCloudUrlFromRegion } from "@posthog/shared"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; +import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useScoutRun } from "../hooks/useScoutRun"; +import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions"; +import { useScoutScratchpad } from "../hooks/useScoutScratchpad"; +import { ScoutEmissionCard } from "./ScoutEmissionCard"; + +export function ScoutRunDetailView({ + skillSlug, + runId, +}: { + skillSlug: string; + runId: string; +}) { + const skillName = scoutSkillNameFromSlug(skillSlug); + const displayName = prettifyScoutSkillName(skillName); + + const headerContent = useMemo( + () => ( + + + + {displayName} · run + + + ), + [displayName], + ); + useSetHeaderContent(headerContent); + + const { data: run, isLoading: runLoading } = useScoutRun(runId); + const emitted = (run?.emitted_count ?? 0) > 0; + const { data: emissions, isLoading: emissionsLoading } = useScoutRunEmissions( + runId, + { enabled: emitted }, + ); + const { data: scratchpad } = useScoutScratchpad(); + const cloudRegion = useAuthStateValue((state) => state.cloudRegion); + + const memoryEntries = useMemo( + () => + (scratchpad ?? []).filter((entry) => entry.created_by_run_id === runId), + [scratchpad, runId], + ); + + const now = new Date(); + const status = run ? normalizeRunStatus(run.status) : "unknown"; + const failureKind = run ? deriveRunFailureKind(run, now) : null; + const duration = run ? formatRunDuration(runDurationSeconds(run, now)) : ""; + const taskLogUrl = + run?.task_url && cloudRegion + ? `${getCloudUrlFromRegion(cloudRegion)}${run.task_url}` + : null; + + return ( + + + + + {displayName} + + + + Run + + {run ? ( + <> + + + {duration ? ( + {duration} + ) : null} + + ) : null} + + {run ? ( + + {run.skill_name} v{run.skill_version} · {run.run_id} + + ) : null} + + +
+
+ {runLoading && !run ? ( + + ) : !run ? ( + + Run not found. It may be older than the recent window the API + returns. + + ) : ( + + + {run.summary ? ( + + + + ) : ( + + + No close-out summary + + + {failureKind === "timed_out" + ? "The run hit the 30-minute deadline before finishing. The task log is the only diagnostic for runs like this." + : status === "failed" + ? "The run failed before writing its close-out. Open the task log to see why." + : "The run has not written a summary yet."} + + + )} + + + + {!emitted ? ( + + Nothing emitted – on a healthy project this is the expected + close-out for most runs. + + ) : emissionsLoading ? ( + + ) : ( + + {(emissions ?? []).map((emission) => ( + + ))} + + )} + + + + {memoryEntries.length === 0 ? ( + + No scratchpad entries created by this run. Entries the run + read or updated in place aren't attributed yet, so this + only shows what it created. + + ) : ( + + {memoryEntries.map((entry) => ( + + ))} + + )} + + + + + + The full transcript lives on the backing task run in + PostHog. For failed runs it is the only way to see what + happened. + + {taskLogUrl ? ( + + Open task log + + + ) : ( + + No task link available + + )} + + + + )} +
+
+
+ ); +} + +function RunStatusBadge({ + status, + emitted, + failureKind, +}: { + status: string; + emitted: boolean; + failureKind: "timed_out" | "error" | null; +}) { + if (status === "failed") { + return ( + + {failureKind === "timed_out" ? "Timed out" : "Failed"} + + ); + } + if (status === "running" || status === "queued") { + return ( + + {status === "running" ? "Running" : "Queued"} + + ); + } + return ( + + {emitted ? "Emitted" : "Quiet"} + + ); +} + +function MemoryEntryCard({ entry }: { entry: ScoutScratchpadEntry }) { + const [expanded, setExpanded] = useState(false); + return ( + + + {expanded ? ( + + {entry.content} + + ) : null} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsSummaryCard.tsx b/packages/ui/src/features/scouts/components/ScoutsSummaryCard.tsx new file mode 100644 index 000000000..26b99ecb7 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutsSummaryCard.tsx @@ -0,0 +1,63 @@ +import { CaretRightIcon, CompassIcon } from "@phosphor-icons/react"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo } from "react"; +import { useScoutConfigs } from "../hooks/useScoutConfigs"; + +/** + * Compact entry card for the agents config page. All scout management lives + * behind it at /code/agents/scouts; this just shows a pulse. + */ +export function ScoutsSummaryCard() { + const { data: configs, isLoading } = useScoutConfigs(); + + const lastRunAt = useMemo(() => { + let latest: string | null = null; + for (const config of configs ?? []) { + if (config.last_run_at && (!latest || config.last_run_at > latest)) { + latest = config.last_run_at; + } + } + return latest; + }, [configs]); + + const enabledCount = (configs ?? []).filter( + (config) => config.enabled, + ).length; + const totalCount = configs?.length ?? 0; + + return ( + + + + + + Manage scouts + + + {isLoading ? ( + "Loading the fleet..." + ) : totalCount === 0 ? ( + "Scheduled agents that sweep this project and emit findings to your inbox." + ) : ( + <> + {enabledCount} of {totalCount} scouts enabled + {lastRunAt ? ( + <> + {" · last dispatched "} + + + ) : null} + + )} + + + + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsView.tsx b/packages/ui/src/features/scouts/components/ScoutsView.tsx new file mode 100644 index 000000000..8e417529d --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutsView.tsx @@ -0,0 +1,277 @@ +import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; +import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; +import { + computeFleetSummary, + computeScoutRollups, + formatRunIntervalShort, + prettifyScoutSkillName, + type ScoutRollup, + scoutSkillSlug, + sortConfigsForDisplay, +} from "@posthog/core/scouts/scoutPresentation"; +import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useMemo, useState } from "react"; +import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; +import { useScoutConfigs } from "../hooks/useScoutConfigs"; +import { useScoutRuns } from "../hooks/useScoutRuns"; +import { + DryRunBadge, + deriveScoutRowState, + ScoutOriginBadge, + ScoutStatusDot, +} from "./ScoutBadges"; +import { ScoutConfigControls } from "./ScoutConfigControls"; + +export function ScoutsView() { + const headerContent = useMemo( + () => ( + + + + Scouts + + + ), + [], + ); + useSetHeaderContent(headerContent); + + const { data: configs, isLoading: configsLoading } = useScoutConfigs(); + const { data: runs } = useScoutRuns(); + const { updateConfig } = useScoutConfigMutations(); + const [hideDisabled, setHideDisabled] = useState(false); + + const rollups = useMemo(() => computeScoutRollups(runs ?? []), [runs]); + const summary = useMemo( + () => computeFleetSummary(configs ?? [], rollups), + [configs, rollups], + ); + const visibleConfigs = useMemo(() => { + const sorted = sortConfigsForDisplay(configs ?? []); + return hideDisabled ? sorted.filter((config) => config.enabled) : sorted; + }, [configs, hideDisabled]); + + return ( + + + + + Agents + + + Scouts + + + Scheduled agents that sweep this project on a cadence and emit + findings to your inbox. A quiet run is the healthy outcome – it means + the scout looked and found nothing worth your attention. + + + +
+
+ {configsLoading ? ( + + ) : !configs || configs.length === 0 ? ( + + ) : ( + + + + {summary.enabledCount} of {summary.totalCount} enabled + {summary.runningCount > 0 + ? ` · ${summary.runningCount} running now` + : ""} + {summary.successRate !== null + ? ` · ${Math.round(summary.successRate * 100)}% success` + : ""} + {` · ${summary.emittedCount} signal${summary.emittedCount === 1 ? "" : "s"} emitted`} + (recent runs) + + + + + + + {visibleConfigs.map((config) => ( + + ))} + + + + Run counts and emitted totals cover the most recent fleet runs + the API returns, not all time. New scouts are created as{" "} + signals-scout-*{" "} + skills in your PostHog project. + + + )} +
+
+
+ ); +} + +function ScoutRow({ + config, + rollup, + onUpdate, +}: { + config: ScoutConfig; + rollup: ScoutRollup | undefined; + onUpdate: ( + configId: string, + updates: Parameters< + ReturnType["updateConfig"] + >[1], + ) => void; +}) { + const now = new Date(); + const state = deriveScoutRowState(config, rollup, now); + + return ( + + + + + + + {prettifyScoutSkillName(config.skill_name)} + + + + + {formatRunIntervalShort(config.run_interval_minutes)} + + + + + + + + ); +} + +function ScoutRowStats({ + config, + rollup, + state, + runningRun, +}: { + config: ScoutConfig; + rollup: ScoutRollup | undefined; + state: string; + runningRun: ScoutRun | null; +}) { + const parts: string[] = []; + if (rollup && rollup.runCount > 0) { + parts.push(`${rollup.runCount} runs`); + parts.push(`${rollup.completedCount} ok / ${rollup.failedCount} failed`); + parts.push( + rollup.emittedCount > 0 + ? `${rollup.emittedCount} signal${rollup.emittedCount === 1 ? "" : "s"}` + : "0 signals (quiet)", + ); + } + + return ( + + {state === "running" && runningRun ? ( + running now + ) : state === "stuck" ? ( + + running past the deadline – may be stuck + + ) : config.last_run_at ? ( + + last ran + + + ) : ( + never run + )} + {parts.length > 0 && ( + + · {parts.join(" · ")} + + )} + + ); +} + +function ScoutsListSkeleton() { + return ( + + {[0, 1, 2, 3].map((index) => ( + + ))} + + ); +} + +function ScoutsEmptyState() { + return ( + + + + + No scouts on this project yet + + + + Scouts are rolling out gradually. Once your project is enrolled, the + canonical fleet appears here automatically and you can add custom scouts + by creating{" "} + signals-scout-* skills in + PostHog. + + + ); +} diff --git a/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts b/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts new file mode 100644 index 000000000..2f9fc66ca --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts @@ -0,0 +1,11 @@ +export const scoutQueryKeys = { + configs: (projectId: number | null) => + ["scouts", "configs", projectId] as const, + runs: (projectId: number | null) => ["scouts", "runs", projectId] as const, + run: (projectId: number | null, runId: string) => + ["scouts", "run", projectId, runId] as const, + emissions: (projectId: number | null, runId: string) => + ["scouts", "emissions", projectId, runId] as const, + scratchpad: (projectId: number | null) => + ["scouts", "scratchpad", projectId] as const, +}; diff --git a/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts new file mode 100644 index 000000000..1fa1ab862 --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts @@ -0,0 +1,56 @@ +import type { ScoutConfig } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; +import { toast } from "sonner"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +export interface ScoutConfigUpdate { + enabled?: boolean; + emit?: boolean; + run_interval_minutes?: number; +} + +/** + * Optimistically patch a scout config (enable/disable, live vs dry-run, + * cadence) and reconcile with the server response. + */ +export function useScoutConfigMutations() { + const client = useAuthenticatedClient(); + const queryClient = useQueryClient(); + const projectId = useAuthStateValue((state) => state.currentProjectId); + + const updateConfig = useCallback( + async (configId: string, updates: ScoutConfigUpdate) => { + if (!client || !projectId) return; + const queryKey = scoutQueryKeys.configs(projectId); + const previous = queryClient.getQueryData(queryKey); + queryClient.setQueryData(queryKey, (configs) => + configs?.map((config) => + config.id === configId ? { ...config, ...updates } : config, + ), + ); + try { + const updated = await client.updateScoutConfig( + projectId, + configId, + updates, + ); + queryClient.setQueryData(queryKey, (configs) => + configs?.map((config) => (config.id === configId ? updated : config)), + ); + } catch (error: unknown) { + queryClient.setQueryData(queryKey, previous); + const message = + error instanceof Error + ? error.message + : "Failed to update scout config"; + toast.error(message); + } + }, + [client, projectId, queryClient], + ); + + return { updateConfig }; +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutConfigs.ts b/packages/ui/src/features/scouts/hooks/useScoutConfigs.ts new file mode 100644 index 000000000..2f96fe22b --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutConfigs.ts @@ -0,0 +1,14 @@ +import type { ScoutConfig } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +export function useScoutConfigs() { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + scoutQueryKeys.configs(projectId), + (client) => + projectId ? client.listScoutConfigs(projectId) : Promise.resolve([]), + { enabled: !!projectId, staleTime: 30_000 }, + ); +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutRun.ts b/packages/ui/src/features/scouts/hooks/useScoutRun.ts new file mode 100644 index 000000000..f1046207e --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutRun.ts @@ -0,0 +1,23 @@ +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useQueryClient } from "@tanstack/react-query"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +export function useScoutRun(runId: string) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + const queryClient = useQueryClient(); + return useAuthenticatedQuery( + scoutQueryKeys.run(projectId, runId), + (client) => + projectId ? client.getScoutRun(projectId, runId) : Promise.resolve(null), + { + enabled: !!projectId && !!runId, + staleTime: 15_000, + initialData: () => + queryClient + .getQueryData(scoutQueryKeys.runs(projectId)) + ?.find((run) => run.run_id === runId) ?? undefined, + }, + ); +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts b/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts new file mode 100644 index 000000000..1e5049d5b --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts @@ -0,0 +1,22 @@ +import type { ScoutEmission } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +export function useScoutRunEmissions( + runId: string, + options?: { enabled?: boolean }, +) { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + scoutQueryKeys.emissions(projectId, runId), + (client) => + projectId + ? client.listScoutRunEmissions(projectId, runId) + : Promise.resolve([]), + { + enabled: !!projectId && !!runId && (options?.enabled ?? true), + staleTime: 60_000, + }, + ); +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutRuns.ts b/packages/ui/src/features/scouts/hooks/useScoutRuns.ts new file mode 100644 index 000000000..c481df00e --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutRuns.ts @@ -0,0 +1,26 @@ +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +/** + * The most recent fleet-wide scout runs (newest first). The backend caps this + * list at 100 rows and has no per-scout filter yet (scouts-ui api gap 1), so + * per-scout views filter this window client-side. Stats derived from it + * describe the visible window, not all time. + */ +export function useScoutRuns() { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + scoutQueryKeys.runs(projectId), + (client) => + projectId + ? client.listScoutRuns(projectId, { limit: 100 }) + : Promise.resolve([]), + { + enabled: !!projectId, + staleTime: 15_000, + refetchInterval: 60_000, + }, + ); +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts b/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts new file mode 100644 index 000000000..ecbede4f3 --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts @@ -0,0 +1,22 @@ +import type { ScoutScratchpadEntry } from "@posthog/api-client/posthog-client"; +import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; +import { useAuthStateValue } from "../../auth/store"; +import { scoutQueryKeys } from "./scoutQueryKeys"; + +/** + * Recent fleet scratchpad memory. The endpoint has no per-run filter, so + * run-detail views select entries by `created_by_run_id` client-side. Reads + * and upsert-updates are not attributed to runs server-side yet (scouts-ui + * api gap 6), so this only ever reveals entries a run CREATED. + */ +export function useScoutScratchpad() { + const projectId = useAuthStateValue((state) => state.currentProjectId); + return useAuthenticatedQuery( + scoutQueryKeys.scratchpad(projectId), + (client) => + projectId + ? client.searchScoutScratchpad(projectId, { limit: 100 }) + : Promise.resolve([]), + { enabled: !!projectId, staleTime: 60_000 }, + ); +} diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 8f79551c0..e5504c9b7 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -25,6 +25,7 @@ import { Route as CodeArchivedRouteImport } from './routes/code/archived' import { Route as CodeAgentsRouteImport } from './routes/code/agents' import { Route as WebsiteChannelIdIndexRouteImport } from './routes/website/$channelId/index' import { Route as CodeInboxIndexRouteImport } from './routes/code/inbox/index' +import { Route as CodeAgentsIndexRouteImport } from './routes/code/agents/index' import { Route as WebsiteChannelIdSettingsRouteImport } from './routes/website/$channelId/settings' import { Route as WebsiteChannelIdNewRouteImport } from './routes/website/$channelId/new' import { Route as CodeTasksTaskIdRouteImport } from './routes/code/tasks/$taskId' @@ -32,15 +33,20 @@ import { Route as CodeInboxRunsRouteImport } from './routes/code/inbox/runs' import { Route as CodeInboxReportsRouteImport } from './routes/code/inbox/reports' import { Route as CodeInboxPullsRouteImport } from './routes/code/inbox/pulls' import { Route as CodeInboxAgentsRouteImport } from './routes/code/inbox/agents' +import { Route as CodeAgentsScoutsRouteImport } from './routes/code/agents/scouts' import { Route as CodeInboxRunsIndexRouteImport } from './routes/code/inbox/runs.index' import { Route as CodeInboxReportsIndexRouteImport } from './routes/code/inbox/reports.index' import { Route as CodeInboxPullsIndexRouteImport } from './routes/code/inbox/pulls.index' +import { Route as CodeAgentsScoutsIndexRouteImport } from './routes/code/agents/scouts.index' import { Route as WebsiteChannelIdTasksTaskIdRouteImport } from './routes/website/$channelId/tasks/$taskId' import { Route as WebsiteChannelIdDashboardsDashboardIdRouteImport } from './routes/website/$channelId/dashboards/$dashboardId' import { Route as CodeTasksPendingKeyRouteImport } from './routes/code/tasks/pending.$key' import { Route as CodeInboxRunsReportIdRouteImport } from './routes/code/inbox/runs.$reportId' import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbox/reports.$reportId' import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' +import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' +import { Route as CodeAgentsScoutsSkillNameIndexRouteImport } from './routes/code/agents/scouts.$skillName.index' +import { Route as CodeAgentsScoutsSkillNameRunsRunIdRouteImport } from './routes/code/agents/scouts.$skillName.runs.$runId' const WebsiteRoute = WebsiteRouteImport.update({ id: '/website', @@ -122,6 +128,11 @@ const CodeInboxIndexRoute = CodeInboxIndexRouteImport.update({ path: '/', getParentRoute: () => CodeInboxRoute, } as any) +const CodeAgentsIndexRoute = CodeAgentsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeAgentsRoute, +} as any) const WebsiteChannelIdSettingsRoute = WebsiteChannelIdSettingsRouteImport.update({ id: '/$channelId/settings', @@ -158,6 +169,11 @@ const CodeInboxAgentsRoute = CodeInboxAgentsRouteImport.update({ path: '/agents', getParentRoute: () => CodeInboxRoute, } as any) +const CodeAgentsScoutsRoute = CodeAgentsScoutsRouteImport.update({ + id: '/scouts', + path: '/scouts', + getParentRoute: () => CodeAgentsRoute, +} as any) const CodeInboxRunsIndexRoute = CodeInboxRunsIndexRouteImport.update({ id: '/', path: '/', @@ -173,6 +189,11 @@ const CodeInboxPullsIndexRoute = CodeInboxPullsIndexRouteImport.update({ path: '/', getParentRoute: () => CodeInboxPullsRoute, } as any) +const CodeAgentsScoutsIndexRoute = CodeAgentsScoutsIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeAgentsScoutsRoute, +} as any) const WebsiteChannelIdTasksTaskIdRoute = WebsiteChannelIdTasksTaskIdRouteImport.update({ id: '/$channelId/tasks/$taskId', @@ -206,6 +227,24 @@ const CodeInboxPullsReportIdRoute = CodeInboxPullsReportIdRouteImport.update({ path: '/$reportId', getParentRoute: () => CodeInboxPullsRoute, } as any) +const CodeAgentsScoutsSkillNameRoute = + CodeAgentsScoutsSkillNameRouteImport.update({ + id: '/$skillName', + path: '/$skillName', + getParentRoute: () => CodeAgentsScoutsRoute, + } as any) +const CodeAgentsScoutsSkillNameIndexRoute = + CodeAgentsScoutsSkillNameIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CodeAgentsScoutsSkillNameRoute, + } as any) +const CodeAgentsScoutsSkillNameRunsRunIdRoute = + CodeAgentsScoutsSkillNameRunsRunIdRouteImport.update({ + id: '/runs/$runId', + path: '/runs/$runId', + getParentRoute: () => CodeAgentsScoutsSkillNameRoute, + } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -213,7 +252,7 @@ export interface FileRoutesByFullPath { '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute '/website': typeof WebsiteRouteWithChildren - '/code/agents': typeof CodeAgentsRoute + '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute '/code/home': typeof CodeHomeRoute '/code/inbox': typeof CodeInboxRouteWithChildren @@ -222,6 +261,7 @@ export interface FileRoutesByFullPath { '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute + '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren '/code/inbox/reports': typeof CodeInboxReportsRouteWithChildren @@ -229,24 +269,28 @@ export interface FileRoutesByFullPath { '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute '/website/$channelId/new': typeof WebsiteChannelIdNewRoute '/website/$channelId/settings': typeof WebsiteChannelIdSettingsRoute + '/code/agents/': typeof CodeAgentsIndexRoute '/code/inbox/': typeof CodeInboxIndexRoute '/website/$channelId/': typeof WebsiteChannelIdIndexRoute + '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute '/code/inbox/runs/$reportId': typeof CodeInboxRunsReportIdRoute '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/scouts/': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute + '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute + '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/command-center': typeof CommandCenterRoute '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute - '/code/agents': typeof CodeAgentsRoute '/code/archived': typeof CodeArchivedRoute '/code/home': typeof CodeHomeRoute '/folders/$folderId': typeof FoldersFolderIdRoute @@ -258,6 +302,7 @@ export interface FileRoutesByTo { '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute '/website/$channelId/new': typeof WebsiteChannelIdNewRoute '/website/$channelId/settings': typeof WebsiteChannelIdSettingsRoute + '/code/agents': typeof CodeAgentsIndexRoute '/code/inbox': typeof CodeInboxIndexRoute '/website/$channelId': typeof WebsiteChannelIdIndexRoute '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute @@ -266,9 +311,12 @@ export interface FileRoutesByTo { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/scouts': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls': typeof CodeInboxPullsIndexRoute '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute + '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameIndexRoute + '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -277,7 +325,7 @@ export interface FileRoutesById { '/mcp-servers': typeof McpServersRoute '/skills': typeof SkillsRoute '/website': typeof WebsiteRouteWithChildren - '/code/agents': typeof CodeAgentsRoute + '/code/agents': typeof CodeAgentsRouteWithChildren '/code/archived': typeof CodeArchivedRoute '/code/home': typeof CodeHomeRoute '/code/inbox': typeof CodeInboxRouteWithChildren @@ -286,6 +334,7 @@ export interface FileRoutesById { '/code/': typeof CodeIndexRoute '/settings/': typeof SettingsIndexRoute '/website/': typeof WebsiteIndexRoute + '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/inbox/pulls': typeof CodeInboxPullsRouteWithChildren '/code/inbox/reports': typeof CodeInboxReportsRouteWithChildren @@ -293,17 +342,22 @@ export interface FileRoutesById { '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute '/website/$channelId/new': typeof WebsiteChannelIdNewRoute '/website/$channelId/settings': typeof WebsiteChannelIdSettingsRoute + '/code/agents/': typeof CodeAgentsIndexRoute '/code/inbox/': typeof CodeInboxIndexRoute '/website/$channelId/': typeof WebsiteChannelIdIndexRoute + '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameRouteWithChildren '/code/inbox/pulls/$reportId': typeof CodeInboxPullsReportIdRoute '/code/inbox/reports/$reportId': typeof CodeInboxReportsReportIdRoute '/code/inbox/runs/$reportId': typeof CodeInboxRunsReportIdRoute '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute + '/code/agents/scouts/': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute + '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute + '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -322,6 +376,7 @@ export interface FileRouteTypes { | '/code/' | '/settings/' | '/website/' + | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/pulls' | '/code/inbox/reports' @@ -329,24 +384,28 @@ export interface FileRouteTypes { | '/code/tasks/$taskId' | '/website/$channelId/new' | '/website/$channelId/settings' + | '/code/agents/' | '/code/inbox/' | '/website/$channelId/' + | '/code/agents/scouts/$skillName' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' | '/code/inbox/runs/$reportId' | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/scouts/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' + | '/code/agents/scouts/$skillName/' + | '/code/agents/scouts/$skillName/runs/$runId' fileRoutesByTo: FileRoutesByTo to: | '/' | '/command-center' | '/mcp-servers' | '/skills' - | '/code/agents' | '/code/archived' | '/code/home' | '/folders/$folderId' @@ -358,6 +417,7 @@ export interface FileRouteTypes { | '/code/tasks/$taskId' | '/website/$channelId/new' | '/website/$channelId/settings' + | '/code/agents' | '/code/inbox' | '/website/$channelId' | '/code/inbox/pulls/$reportId' @@ -366,9 +426,12 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/scouts' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' + | '/code/agents/scouts/$skillName' + | '/code/agents/scouts/$skillName/runs/$runId' id: | '__root__' | '/' @@ -385,6 +448,7 @@ export interface FileRouteTypes { | '/code/' | '/settings/' | '/website/' + | '/code/agents/scouts' | '/code/inbox/agents' | '/code/inbox/pulls' | '/code/inbox/reports' @@ -392,17 +456,22 @@ export interface FileRouteTypes { | '/code/tasks/$taskId' | '/website/$channelId/new' | '/website/$channelId/settings' + | '/code/agents/' | '/code/inbox/' | '/website/$channelId/' + | '/code/agents/scouts/$skillName' | '/code/inbox/pulls/$reportId' | '/code/inbox/reports/$reportId' | '/code/inbox/runs/$reportId' | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' + | '/code/agents/scouts/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' + | '/code/agents/scouts/$skillName/' + | '/code/agents/scouts/$skillName/runs/$runId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -411,7 +480,7 @@ export interface RootRouteChildren { McpServersRoute: typeof McpServersRoute SkillsRoute: typeof SkillsRoute WebsiteRoute: typeof WebsiteRouteWithChildren - CodeAgentsRoute: typeof CodeAgentsRoute + CodeAgentsRoute: typeof CodeAgentsRouteWithChildren CodeArchivedRoute: typeof CodeArchivedRoute CodeHomeRoute: typeof CodeHomeRoute CodeInboxRoute: typeof CodeInboxRouteWithChildren @@ -537,6 +606,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxIndexRouteImport parentRoute: typeof CodeInboxRoute } + '/code/agents/': { + id: '/code/agents/' + path: '/' + fullPath: '/code/agents/' + preLoaderRoute: typeof CodeAgentsIndexRouteImport + parentRoute: typeof CodeAgentsRoute + } '/website/$channelId/settings': { id: '/website/$channelId/settings' path: '/$channelId/settings' @@ -586,6 +662,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxAgentsRouteImport parentRoute: typeof CodeInboxRoute } + '/code/agents/scouts': { + id: '/code/agents/scouts' + path: '/scouts' + fullPath: '/code/agents/scouts' + preLoaderRoute: typeof CodeAgentsScoutsRouteImport + parentRoute: typeof CodeAgentsRoute + } '/code/inbox/runs/': { id: '/code/inbox/runs/' path: '/' @@ -607,6 +690,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsIndexRouteImport parentRoute: typeof CodeInboxPullsRoute } + '/code/agents/scouts/': { + id: '/code/agents/scouts/' + path: '/' + fullPath: '/code/agents/scouts/' + preLoaderRoute: typeof CodeAgentsScoutsIndexRouteImport + parentRoute: typeof CodeAgentsScoutsRoute + } '/website/$channelId/tasks/$taskId': { id: '/website/$channelId/tasks/$taskId' path: '/$channelId/tasks/$taskId' @@ -649,6 +739,27 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsReportIdRouteImport parentRoute: typeof CodeInboxPullsRoute } + '/code/agents/scouts/$skillName': { + id: '/code/agents/scouts/$skillName' + path: '/$skillName' + fullPath: '/code/agents/scouts/$skillName' + preLoaderRoute: typeof CodeAgentsScoutsSkillNameRouteImport + parentRoute: typeof CodeAgentsScoutsRoute + } + '/code/agents/scouts/$skillName/': { + id: '/code/agents/scouts/$skillName/' + path: '/' + fullPath: '/code/agents/scouts/$skillName/' + preLoaderRoute: typeof CodeAgentsScoutsSkillNameIndexRouteImport + parentRoute: typeof CodeAgentsScoutsSkillNameRoute + } + '/code/agents/scouts/$skillName/runs/$runId': { + id: '/code/agents/scouts/$skillName/runs/$runId' + path: '/runs/$runId' + fullPath: '/code/agents/scouts/$skillName/runs/$runId' + preLoaderRoute: typeof CodeAgentsScoutsSkillNameRunsRunIdRouteImport + parentRoute: typeof CodeAgentsScoutsSkillNameRoute + } } } @@ -674,6 +785,50 @@ const WebsiteRouteChildren: WebsiteRouteChildren = { const WebsiteRouteWithChildren = WebsiteRoute._addFileChildren(WebsiteRouteChildren) +interface CodeAgentsScoutsSkillNameRouteChildren { + CodeAgentsScoutsSkillNameIndexRoute: typeof CodeAgentsScoutsSkillNameIndexRoute + CodeAgentsScoutsSkillNameRunsRunIdRoute: typeof CodeAgentsScoutsSkillNameRunsRunIdRoute +} + +const CodeAgentsScoutsSkillNameRouteChildren: CodeAgentsScoutsSkillNameRouteChildren = + { + CodeAgentsScoutsSkillNameIndexRoute: CodeAgentsScoutsSkillNameIndexRoute, + CodeAgentsScoutsSkillNameRunsRunIdRoute: + CodeAgentsScoutsSkillNameRunsRunIdRoute, + } + +const CodeAgentsScoutsSkillNameRouteWithChildren = + CodeAgentsScoutsSkillNameRoute._addFileChildren( + CodeAgentsScoutsSkillNameRouteChildren, + ) + +interface CodeAgentsScoutsRouteChildren { + CodeAgentsScoutsSkillNameRoute: typeof CodeAgentsScoutsSkillNameRouteWithChildren + CodeAgentsScoutsIndexRoute: typeof CodeAgentsScoutsIndexRoute +} + +const CodeAgentsScoutsRouteChildren: CodeAgentsScoutsRouteChildren = { + CodeAgentsScoutsSkillNameRoute: CodeAgentsScoutsSkillNameRouteWithChildren, + CodeAgentsScoutsIndexRoute: CodeAgentsScoutsIndexRoute, +} + +const CodeAgentsScoutsRouteWithChildren = + CodeAgentsScoutsRoute._addFileChildren(CodeAgentsScoutsRouteChildren) + +interface CodeAgentsRouteChildren { + CodeAgentsScoutsRoute: typeof CodeAgentsScoutsRouteWithChildren + CodeAgentsIndexRoute: typeof CodeAgentsIndexRoute +} + +const CodeAgentsRouteChildren: CodeAgentsRouteChildren = { + CodeAgentsScoutsRoute: CodeAgentsScoutsRouteWithChildren, + CodeAgentsIndexRoute: CodeAgentsIndexRoute, +} + +const CodeAgentsRouteWithChildren = CodeAgentsRoute._addFileChildren( + CodeAgentsRouteChildren, +) + interface CodeInboxPullsRouteChildren { CodeInboxPullsReportIdRoute: typeof CodeInboxPullsReportIdRoute CodeInboxPullsIndexRoute: typeof CodeInboxPullsIndexRoute @@ -741,7 +896,7 @@ const rootRouteChildren: RootRouteChildren = { McpServersRoute: McpServersRoute, SkillsRoute: SkillsRoute, WebsiteRoute: WebsiteRouteWithChildren, - CodeAgentsRoute: CodeAgentsRoute, + CodeAgentsRoute: CodeAgentsRouteWithChildren, CodeArchivedRoute: CodeArchivedRoute, CodeHomeRoute: CodeHomeRoute, CodeInboxRoute: CodeInboxRouteWithChildren, diff --git a/packages/ui/src/router/routes/code/agents.tsx b/packages/ui/src/router/routes/code/agents.tsx index 13fa7ac6c..f939a23a9 100644 --- a/packages/ui/src/router/routes/code/agents.tsx +++ b/packages/ui/src/router/routes/code/agents.tsx @@ -1,6 +1,5 @@ -import { AgentsView } from "@posthog/ui/features/agents/components/AgentsView"; -import { createFileRoute } from "@tanstack/react-router"; +import { createFileRoute, Outlet } from "@tanstack/react-router"; export const Route = createFileRoute("/code/agents")({ - component: AgentsView, + component: Outlet, }); diff --git a/packages/ui/src/router/routes/code/agents/index.tsx b/packages/ui/src/router/routes/code/agents/index.tsx new file mode 100644 index 000000000..fdbb6fce3 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/index.tsx @@ -0,0 +1,6 @@ +import { AgentsView } from "@posthog/ui/features/agents/components/AgentsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/")({ + component: AgentsView, +}); diff --git a/packages/ui/src/router/routes/code/agents/scouts.$skillName.index.tsx b/packages/ui/src/router/routes/code/agents/scouts.$skillName.index.tsx new file mode 100644 index 000000000..3b76909a4 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.$skillName.index.tsx @@ -0,0 +1,11 @@ +import { ScoutDetailView } from "@posthog/ui/features/scouts/components/ScoutDetailView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts/$skillName/")({ + component: ScoutDetailRoute, +}); + +function ScoutDetailRoute() { + const { skillName } = Route.useParams(); + return ; +} diff --git a/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx b/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx new file mode 100644 index 000000000..805956bfd --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx @@ -0,0 +1,13 @@ +import { ScoutRunDetailView } from "@posthog/ui/features/scouts/components/ScoutRunDetailView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute( + "/code/agents/scouts/$skillName/runs/$runId", +)({ + component: ScoutRunDetailRoute, +}); + +function ScoutRunDetailRoute() { + const { skillName, runId } = Route.useParams(); + return ; +} diff --git a/packages/ui/src/router/routes/code/agents/scouts.$skillName.tsx b/packages/ui/src/router/routes/code/agents/scouts.$skillName.tsx new file mode 100644 index 000000000..426ca6201 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.$skillName.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts/$skillName")({ + component: Outlet, +}); diff --git a/packages/ui/src/router/routes/code/agents/scouts.index.tsx b/packages/ui/src/router/routes/code/agents/scouts.index.tsx new file mode 100644 index 000000000..e61ca8e92 --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.index.tsx @@ -0,0 +1,6 @@ +import { ScoutsView } from "@posthog/ui/features/scouts/components/ScoutsView"; +import { createFileRoute } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts/")({ + component: ScoutsView, +}); diff --git a/packages/ui/src/router/routes/code/agents/scouts.tsx b/packages/ui/src/router/routes/code/agents/scouts.tsx new file mode 100644 index 000000000..dcdbb7f9c --- /dev/null +++ b/packages/ui/src/router/routes/code/agents/scouts.tsx @@ -0,0 +1,5 @@ +import { createFileRoute, Outlet } from "@tanstack/react-router"; + +export const Route = createFileRoute("/code/agents/scouts")({ + component: Outlet, +}); From bf9828e081d80624de209e2933d3dcc40e791a4a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 14:33:05 +0100 Subject: [PATCH 02/37] fix(scouts): stop config selects overflowing fleet row cards SettingsOptionSelect hardcodes w-full on its trigger; as a direct flex child in the scout row the interval select stretched against the row and pushed the enable toggle past the card edge. Fixed trigger widths keep the controls in a tidy column (w-36 fits the longest cadence label). --- .../ui/src/features/scouts/components/ScoutConfigControls.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx index 11d429462..d2eb52937 100644 --- a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx +++ b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx @@ -54,6 +54,7 @@ export function ScoutConfigControls({ options={MODE_OPTIONS} ariaLabel={`${config.skill_name} mode`} disabled={!config.enabled} + className="w-24" onValueChange={(value) => onUpdate(config.id, { emit: value === "live" }) } @@ -65,6 +66,7 @@ export function ScoutConfigControls({ options={intervalOptions} ariaLabel={`${config.skill_name} run interval`} disabled={!config.enabled} + className="w-36" onValueChange={(value) => onUpdate(config.id, { run_interval_minutes: Number(value) }) } From ccc1d5e9550993622fe878731c0343dcc028a179 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 15:26:48 +0100 Subject: [PATCH 03/37] feat(scouts): inline expandable fleet section + 24h run window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace the standalone /code/agents/scouts fleet screen and summary card with ScoutsFleetSection, an expandable section on the agents page (collapsed pulse line; expanded list capped at ~10 rows with inner scroll). Detail routes survive; back link now goes to Agents. - Scout run stats now describe a true last-24h window: core fetchScoutRunsWindow walks the backend's 100-row pages on a started_at cursor with run_id dedupe (created_at isn't serialized – api gap 7), flagging truncation honestly. All window copy updated. - Status dots get tooltips + aria-labels explaining each state. - Runs polling now only happens while the section is expanded. --- .../core/src/scouts/scoutRunsWindow.test.ts | 127 ++++++++++ packages/core/src/scouts/scoutRunsWindow.ts | 91 ++++++++ .../components/ConfigureAgentsSection.tsx | 6 +- .../scouts/components/ScoutBadges.tsx | 21 +- .../scouts/components/ScoutDetailView.tsx | 35 +-- ...{ScoutsView.tsx => ScoutsFleetSection.tsx} | 221 +++++++++--------- .../scouts/components/ScoutsSummaryCard.tsx | 63 ----- .../src/features/scouts/hooks/useScoutRuns.ts | 19 +- packages/ui/src/router/routeTree.gen.ts | 23 +- .../routes/code/agents/scouts.index.tsx | 6 - 10 files changed, 389 insertions(+), 223 deletions(-) create mode 100644 packages/core/src/scouts/scoutRunsWindow.test.ts create mode 100644 packages/core/src/scouts/scoutRunsWindow.ts rename packages/ui/src/features/scouts/components/{ScoutsView.tsx => ScoutsFleetSection.tsx} (54%) delete mode 100644 packages/ui/src/features/scouts/components/ScoutsSummaryCard.tsx delete mode 100644 packages/ui/src/router/routes/code/agents/scouts.index.tsx diff --git a/packages/core/src/scouts/scoutRunsWindow.test.ts b/packages/core/src/scouts/scoutRunsWindow.test.ts new file mode 100644 index 000000000..b954556d1 --- /dev/null +++ b/packages/core/src/scouts/scoutRunsWindow.test.ts @@ -0,0 +1,127 @@ +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { describe, expect, it } from "vitest"; +import { + fetchScoutRunsWindow, + SCOUT_RUNS_WINDOW_HOURS, + type ScoutRunsClient, + scoutRunsWindowLabel, +} from "./scoutRunsWindow"; + +const NOW = new Date("2026-06-10T12:00:00.000Z"); + +function makeRun(id: string, startedAt: string | null): ScoutRun { + return { + run_id: id, + skill_name: "signals-scout-general", + skill_version: 1, + status: "completed", + started_at: startedAt, + completed_at: startedAt, + task_id: null, + task_run_id: null, + task_url: null, + summary: "", + emitted_count: 0, + emitted_finding_ids: [], + }; +} + +function clientFromPages(pages: ScoutRun[][]): { + client: ScoutRunsClient; + calls: Array<{ date_from?: string; date_to?: string; limit?: number }>; +} { + const calls: Array<{ date_from?: string; date_to?: string; limit?: number }> = + []; + let index = 0; + return { + calls, + client: { + listScoutRuns: (_projectId, params) => { + calls.push({ ...params }); + const page = pages[index] ?? []; + index++; + return Promise.resolve(page); + }, + }, + }; +} + +function fullPage(prefix: string, hourOffset: number): ScoutRun[] { + return Array.from({ length: 100 }, (_, i) => + makeRun( + `${prefix}-${i}`, + new Date( + NOW.getTime() - hourOffset * 3_600_000 - i * 60_000, + ).toISOString(), + ), + ); +} + +describe("fetchScoutRunsWindow", () => { + it("returns a complete window from a single short page", async () => { + const runs = [makeRun("a", "2026-06-10T11:00:00.000Z")]; + const { client, calls } = clientFromPages([runs]); + + const window = await fetchScoutRunsWindow(client, 1, NOW); + + expect(window.complete).toBe(true); + expect(window.runs).toHaveLength(1); + expect(calls).toHaveLength(1); + expect(calls[0]?.date_from).toBe( + new Date( + NOW.getTime() - SCOUT_RUNS_WINDOW_HOURS * 3_600_000, + ).toISOString(), + ); + expect(calls[0]?.date_to).toBeUndefined(); + expect(calls[0]?.limit).toBe(100); + }); + + it("walks date_to past full pages and dedupes boundary repeats", async () => { + const first = fullPage("p1", 0); + const boundary = first[99]; + if (!boundary) throw new Error("expected boundary run"); + // Second page re-includes the boundary run (timestamp drift), then ends short. + const second = [boundary, makeRun("p2-0", "2026-06-10T01:00:00.000Z")]; + const { client, calls } = clientFromPages([first, second]); + + const window = await fetchScoutRunsWindow(client, 1, NOW); + + expect(window.complete).toBe(true); + expect(window.runs).toHaveLength(101); + expect(calls).toHaveLength(2); + expect(calls[1]?.date_to).toBe(boundary.started_at); + }); + + it("reports an incomplete window when the cursor cannot advance", async () => { + const page = fullPage("p1", 0); + const { client } = clientFromPages([page, page]); + + const window = await fetchScoutRunsWindow(client, 1, NOW); + + expect(window.complete).toBe(false); + expect(window.runs).toHaveLength(100); + }); + + it("stops at the page cap and reports incomplete", async () => { + const pages = Array.from({ length: 12 }, (_, i) => fullPage(`p${i}`, i)); + const { client, calls } = clientFromPages(pages); + + const window = await fetchScoutRunsWindow(client, 1, NOW); + + expect(window.complete).toBe(false); + expect(window.runs).toHaveLength(1000); + expect(calls).toHaveLength(10); + }); +}); + +describe("scoutRunsWindowLabel", () => { + it("names the window and flags truncation", () => { + expect(scoutRunsWindowLabel({ runs: [], complete: true })).toBe( + "last 24 h", + ); + expect(scoutRunsWindowLabel({ runs: [], complete: false })).toBe( + "last 24 h · truncated", + ); + expect(scoutRunsWindowLabel(undefined)).toBe("last 24 h"); + }); +}); diff --git a/packages/core/src/scouts/scoutRunsWindow.ts b/packages/core/src/scouts/scoutRunsWindow.ts new file mode 100644 index 000000000..34a5d8bcd --- /dev/null +++ b/packages/core/src/scouts/scoutRunsWindow.ts @@ -0,0 +1,91 @@ +import type { + ScoutRun, + ScoutRunsQueryParams, +} from "@posthog/api-client/posthog-client"; + +/** + * The fleet run window every scout stat describes. The backend caps each + * runs-list response at 100 rows, so covering a fixed time window means + * walking `date_to` back page by page (the endpoint documents this cursor + * pattern). A fixed window gives users a stable frame for every number; + * "the most recent 100 runs" does not. + */ +export const SCOUT_RUNS_WINDOW_HOURS = 24; + +const PAGE_LIMIT = 100; +const MAX_PAGES = 10; + +export interface ScoutRunsClient { + listScoutRuns( + projectId: number, + params?: ScoutRunsQueryParams, + ): Promise; +} + +export interface ScoutRunsWindow { + /** Newest-first runs created within the window. */ + runs: ScoutRun[]; + /** False when pagination stopped (page cap or stuck cursor) before reaching the window start. */ + complete: boolean; +} + +/** Label for stats derived from a window, e.g. "last 24 h". */ +export function scoutRunsWindowLabel(window?: ScoutRunsWindow): string { + const base = `last ${SCOUT_RUNS_WINDOW_HOURS} h`; + return window && !window.complete ? `${base} · truncated` : base; +} + +/** + * Fetch every fleet run from the last SCOUT_RUNS_WINDOW_HOURS hours. + * + * The backend filters and orders on `created_at` but does not serialize it; + * `started_at` (the linked TaskRun's creation time) is the closest available + * cursor, so pages are deduped by run_id to absorb boundary drift between the + * two timestamps. + */ +export async function fetchScoutRunsWindow( + client: ScoutRunsClient, + projectId: number, + now: Date = new Date(), +): Promise { + const dateFrom = new Date( + now.getTime() - SCOUT_RUNS_WINDOW_HOURS * 60 * 60 * 1000, + ).toISOString(); + + const runsById = new Map(); + let dateTo: string | undefined; + + for (let page = 0; page < MAX_PAGES; page++) { + const batch = await client.listScoutRuns(projectId, { + date_from: dateFrom, + date_to: dateTo, + limit: PAGE_LIMIT, + }); + + let added = 0; + let oldestStartedAt: string | undefined; + for (const run of batch) { + if (!runsById.has(run.run_id)) { + runsById.set(run.run_id, run); + added++; + } + if ( + run.started_at && + (!oldestStartedAt || run.started_at < oldestStartedAt) + ) { + oldestStartedAt = run.started_at; + } + } + + if (batch.length < PAGE_LIMIT) { + return { runs: [...runsById.values()], complete: true }; + } + if (added === 0 || !oldestStartedAt || oldestStartedAt === dateTo) { + // Cursor cannot move: either pure duplicates or runs without started_at. + return { runs: [...runsById.values()], complete: false }; + } + dateTo = oldestStartedAt; + } + + return { runs: [...runsById.values()], complete: false }; +} diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 75e503e40..6720a23eb 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -27,7 +27,7 @@ import { useRepositoryIntegration, useUserRepositoryIntegration, } from "@posthog/ui/features/integrations/useIntegrations"; -import { ScoutsSummaryCard } from "@posthog/ui/features/scouts/components/ScoutsSummaryCard"; +import { ScoutsFleetSection } from "@posthog/ui/features/scouts/components/ScoutsFleetSection"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; @@ -108,9 +108,9 @@ export function ConfigureAgentsSection() { - + = { disabled: "bg-(--gray-7)", }; +const ROW_STATE_LABELS: Record = { + ok: "Healthy – last run completed", + running: "Running now", + failing: "Last run failed", + stuck: "Running past the deadline – may be stuck", + disabled: "Disabled", +}; + export function ScoutStatusDot({ state }: { state: ScoutRowState }) { return ( - + + + ); } diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index 2c2c5a046..661d710dd 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -12,6 +12,10 @@ import { scoutSkillNameFromSlug, scoutSkillSlug, } from "@posthog/core/scouts/scoutPresentation"; +import { + SCOUT_RUNS_WINDOW_HOURS, + scoutRunsWindowLabel, +} from "@posthog/core/scouts/scoutRunsWindow"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; @@ -56,16 +60,17 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { useSetHeaderContent(headerContent); const { data: configs } = useScoutConfigs(); - const { data: runs, isLoading: runsLoading } = useScoutRuns(); + const { data: runsWindow, isLoading: runsLoading } = useScoutRuns(); const { updateConfig } = useScoutConfigMutations(); const [filter, setFilter] = useState("all"); const config = configs?.find((entry) => entry.skill_name === skillName); // The runs endpoint has no skill_name filter yet (scouts-ui api gap 1), so - // select this scout's runs from the recent fleet window client-side. + // select this scout's runs from the 24-hour fleet window client-side. const scoutRuns = useMemo( - () => (runs ?? []).filter((run) => run.skill_name === skillName), - [runs, skillName], + () => + (runsWindow?.runs ?? []).filter((run) => run.skill_name === skillName), + [runsWindow, skillName], ); const rollup = useMemo( () => computeScoutRollups(scoutRuns).get(skillName), @@ -99,11 +104,11 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { className="cursor-default select-none border-(--gray-5) border-b px-6 pt-5 pb-5" > - Scouts + Agents @@ -155,9 +160,9 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { {rollup && rollup.runCount > 0 ? ( - Recent window: {rollup.runCount} runs · {rollup.completedCount}{" "} - completed · {rollup.failedCount} failed · {rollup.emittedCount}{" "} - signal + {capitalize(scoutRunsWindowLabel(runsWindow))}:{" "} + {rollup.runCount} runs · {rollup.completedCount} completed ·{" "} + {rollup.failedCount} failed · {rollup.emittedCount} signal {rollup.emittedCount === 1 ? "" : "s"} emitted ) : null} @@ -189,8 +194,8 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { ) : filteredRuns.length === 0 ? ( {scoutRuns.length === 0 - ? "No runs in the recent window the API returns." - : "No runs match this filter in the recent window."} + ? `No runs in the ${scoutRunsWindowLabel(runsWindow)}.` + : `No runs match this filter in the ${scoutRunsWindowLabel(runsWindow)}.`} ) : ( @@ -205,8 +210,8 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { )} - Showing this scout's runs from the most recent fleet runs - the API returns (currently capped at 100 fleet-wide). + Showing this scout's runs from the last{" "} + {SCOUT_RUNS_WINDOW_HOURS} hours. @@ -286,6 +291,10 @@ function RunGlyph({ status, emitted }: { status: string; emitted: number }) { return ·; } +function capitalize(value: string): string { + return value.charAt(0).toUpperCase() + value.slice(1); +} + function RunListSkeleton() { return ( diff --git a/packages/ui/src/features/scouts/components/ScoutsView.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx similarity index 54% rename from packages/ui/src/features/scouts/components/ScoutsView.tsx rename to packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 8e417529d..0902e7806 100644 --- a/packages/ui/src/features/scouts/components/ScoutsView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,4 +1,4 @@ -import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; +import { CaretDownIcon, CompassIcon } from "@phosphor-icons/react"; import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, @@ -9,7 +9,10 @@ import { scoutSkillSlug, sortConfigsForDisplay, } from "@posthog/core/scouts/scoutPresentation"; -import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; +import { + SCOUT_RUNS_WINDOW_HOURS, + scoutRunsWindowLabel, +} from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Box, Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; @@ -25,113 +28,136 @@ import { } from "./ScoutBadges"; import { ScoutConfigControls } from "./ScoutConfigControls"; -export function ScoutsView() { - const headerContent = useMemo( - () => ( - - - - Scouts - - - ), - [], +/** + * Expandable scout fleet manager for the agents config page. Collapsed it is + * a one-line pulse; expanded it lists every scout with inline config controls. + * Per-scout drill-down (run history, run detail) stays on its own routes. + */ +export function ScoutsFleetSection() { + const { data: configs, isLoading } = useScoutConfigs(); + const [expanded, setExpanded] = useState(false); + + const lastRunAt = useMemo(() => { + let latest: string | null = null; + for (const config of configs ?? []) { + if (config.last_run_at && (!latest || config.last_run_at > latest)) { + latest = config.last_run_at; + } + } + return latest; + }, [configs]); + + if (isLoading) { + return ( + + ); + } + + if (!configs || configs.length === 0) { + return ; + } + + const enabledCount = configs.filter((config) => config.enabled).length; + + return ( + + + {expanded ? : null} + ); - useSetHeaderContent(headerContent); +} - const { data: configs, isLoading: configsLoading } = useScoutConfigs(); - const { data: runs } = useScoutRuns(); +function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { + const { data: runsWindow } = useScoutRuns(); const { updateConfig } = useScoutConfigMutations(); const [hideDisabled, setHideDisabled] = useState(false); + const runs = runsWindow?.runs; const rollups = useMemo(() => computeScoutRollups(runs ?? []), [runs]); const summary = useMemo( - () => computeFleetSummary(configs ?? [], rollups), + () => computeFleetSummary(configs, rollups), [configs, rollups], ); const visibleConfigs = useMemo(() => { - const sorted = sortConfigsForDisplay(configs ?? []); + const sorted = sortConfigsForDisplay(configs); return hideDisabled ? sorted.filter((config) => config.enabled) : sorted; }, [configs, hideDisabled]); return ( - - - - - Agents - - - Scouts - - - Scheduled agents that sweep this project on a cadence and emit - findings to your inbox. A quiet run is the healthy outcome – it means - the scout looked and found nothing worth your attention. + + + + {summary.runningCount > 0 + ? `${summary.runningCount} running now` + : "none running now"} + {summary.successRate !== null + ? ` · ${Math.round(summary.successRate * 100)}% success` + : ""} + {` · ${summary.emittedCount} signal${summary.emittedCount === 1 ? "" : "s"} emitted`} + + {" "} + ({scoutRunsWindowLabel(runsWindow)}) + + + -
-
- {configsLoading ? ( - - ) : !configs || configs.length === 0 ? ( - - ) : ( - - - - {summary.enabledCount} of {summary.totalCount} enabled - {summary.runningCount > 0 - ? ` · ${summary.runningCount} running now` - : ""} - {summary.successRate !== null - ? ` · ${Math.round(summary.successRate * 100)}% success` - : ""} - {` · ${summary.emittedCount} signal${summary.emittedCount === 1 ? "" : "s"} emitted`} - (recent runs) - - - - - - - {visibleConfigs.map((config) => ( - - ))} - - - - Run counts and emitted totals cover the most recent fleet runs - the API returns, not all time. New scouts are created as{" "} - signals-scout-*{" "} - skills in your PostHog project. - - - )} -
+ {/* Bounded to roughly 10 rows; larger fleets scroll within the section. */} +
+ + {visibleConfigs.map((config) => ( + + ))} +
+ + + Run counts and emitted totals cover the last {SCOUT_RUNS_WINDOW_HOURS}{" "} + hours of fleet runs. New scouts are created as{" "} + signals-scout-* skills in + your PostHog project. + ); } @@ -238,19 +264,6 @@ function ScoutRowStats({ ); } -function ScoutsListSkeleton() { - return ( - - {[0, 1, 2, 3].map((index) => ( - - ))} - - ); -} - function ScoutsEmptyState() { return ( { - let latest: string | null = null; - for (const config of configs ?? []) { - if (config.last_run_at && (!latest || config.last_run_at > latest)) { - latest = config.last_run_at; - } - } - return latest; - }, [configs]); - - const enabledCount = (configs ?? []).filter( - (config) => config.enabled, - ).length; - const totalCount = configs?.length ?? 0; - - return ( - - - - - - Manage scouts - - - {isLoading ? ( - "Loading the fleet..." - ) : totalCount === 0 ? ( - "Scheduled agents that sweep this project and emit findings to your inbox." - ) : ( - <> - {enabledCount} of {totalCount} scouts enabled - {lastRunAt ? ( - <> - {" · last dispatched "} - - - ) : null} - - )} - - - - - - ); -} diff --git a/packages/ui/src/features/scouts/hooks/useScoutRuns.ts b/packages/ui/src/features/scouts/hooks/useScoutRuns.ts index c481df00e..b810b015e 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutRuns.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutRuns.ts @@ -1,22 +1,25 @@ -import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { + fetchScoutRunsWindow, + type ScoutRunsWindow, +} from "@posthog/core/scouts/scoutRunsWindow"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useAuthStateValue } from "../../auth/store"; import { scoutQueryKeys } from "./scoutQueryKeys"; /** - * The most recent fleet-wide scout runs (newest first). The backend caps this - * list at 100 rows and has no per-scout filter yet (scouts-ui api gap 1), so - * per-scout views filter this window client-side. Stats derived from it - * describe the visible window, not all time. + * Fleet-wide scout runs from the last 24 hours (newest first), assembled in + * core by walking the backend's 100-row pages. The backend has no per-scout + * filter yet (scouts-ui api gap 1), so per-scout views filter this window + * client-side. `complete` is false if pagination had to stop early. */ export function useScoutRuns() { const projectId = useAuthStateValue((state) => state.currentProjectId); - return useAuthenticatedQuery( + return useAuthenticatedQuery( scoutQueryKeys.runs(projectId), (client) => projectId - ? client.listScoutRuns(projectId, { limit: 100 }) - : Promise.resolve([]), + ? fetchScoutRunsWindow(client, projectId) + : Promise.resolve({ runs: [], complete: true }), { enabled: !!projectId, staleTime: 15_000, diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index e5504c9b7..22965cea3 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -37,7 +37,6 @@ import { Route as CodeAgentsScoutsRouteImport } from './routes/code/agents/scout import { Route as CodeInboxRunsIndexRouteImport } from './routes/code/inbox/runs.index' import { Route as CodeInboxReportsIndexRouteImport } from './routes/code/inbox/reports.index' import { Route as CodeInboxPullsIndexRouteImport } from './routes/code/inbox/pulls.index' -import { Route as CodeAgentsScoutsIndexRouteImport } from './routes/code/agents/scouts.index' import { Route as WebsiteChannelIdTasksTaskIdRouteImport } from './routes/website/$channelId/tasks/$taskId' import { Route as WebsiteChannelIdDashboardsDashboardIdRouteImport } from './routes/website/$channelId/dashboards/$dashboardId' import { Route as CodeTasksPendingKeyRouteImport } from './routes/code/tasks/pending.$key' @@ -189,11 +188,6 @@ const CodeInboxPullsIndexRoute = CodeInboxPullsIndexRouteImport.update({ path: '/', getParentRoute: () => CodeInboxPullsRoute, } as any) -const CodeAgentsScoutsIndexRoute = CodeAgentsScoutsIndexRouteImport.update({ - id: '/', - path: '/', - getParentRoute: () => CodeAgentsScoutsRoute, -} as any) const WebsiteChannelIdTasksTaskIdRoute = WebsiteChannelIdTasksTaskIdRouteImport.update({ id: '/$channelId/tasks/$taskId', @@ -279,7 +273,6 @@ export interface FileRoutesByFullPath { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute - '/code/agents/scouts/': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -298,6 +291,7 @@ export interface FileRoutesByTo { '/code': typeof CodeIndexRoute '/settings': typeof SettingsIndexRoute '/website': typeof WebsiteIndexRoute + '/code/agents/scouts': typeof CodeAgentsScoutsRouteWithChildren '/code/inbox/agents': typeof CodeInboxAgentsRoute '/code/tasks/$taskId': typeof CodeTasksTaskIdRoute '/website/$channelId/new': typeof WebsiteChannelIdNewRoute @@ -311,7 +305,6 @@ export interface FileRoutesByTo { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute - '/code/agents/scouts': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls': typeof CodeInboxPullsIndexRoute '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute @@ -352,7 +345,6 @@ export interface FileRoutesById { '/code/tasks/pending/$key': typeof CodeTasksPendingKeyRoute '/website/$channelId/dashboards/$dashboardId': typeof WebsiteChannelIdDashboardsDashboardIdRoute '/website/$channelId/tasks/$taskId': typeof WebsiteChannelIdTasksTaskIdRoute - '/code/agents/scouts/': typeof CodeAgentsScoutsIndexRoute '/code/inbox/pulls/': typeof CodeInboxPullsIndexRoute '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute @@ -394,7 +386,6 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' - | '/code/agents/scouts/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -413,6 +404,7 @@ export interface FileRouteTypes { | '/code' | '/settings' | '/website' + | '/code/agents/scouts' | '/code/inbox/agents' | '/code/tasks/$taskId' | '/website/$channelId/new' @@ -426,7 +418,6 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' - | '/code/agents/scouts' | '/code/inbox/pulls' | '/code/inbox/reports' | '/code/inbox/runs' @@ -466,7 +457,6 @@ export interface FileRouteTypes { | '/code/tasks/pending/$key' | '/website/$channelId/dashboards/$dashboardId' | '/website/$channelId/tasks/$taskId' - | '/code/agents/scouts/' | '/code/inbox/pulls/' | '/code/inbox/reports/' | '/code/inbox/runs/' @@ -690,13 +680,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeInboxPullsIndexRouteImport parentRoute: typeof CodeInboxPullsRoute } - '/code/agents/scouts/': { - id: '/code/agents/scouts/' - path: '/' - fullPath: '/code/agents/scouts/' - preLoaderRoute: typeof CodeAgentsScoutsIndexRouteImport - parentRoute: typeof CodeAgentsScoutsRoute - } '/website/$channelId/tasks/$taskId': { id: '/website/$channelId/tasks/$taskId' path: '/$channelId/tasks/$taskId' @@ -804,12 +787,10 @@ const CodeAgentsScoutsSkillNameRouteWithChildren = interface CodeAgentsScoutsRouteChildren { CodeAgentsScoutsSkillNameRoute: typeof CodeAgentsScoutsSkillNameRouteWithChildren - CodeAgentsScoutsIndexRoute: typeof CodeAgentsScoutsIndexRoute } const CodeAgentsScoutsRouteChildren: CodeAgentsScoutsRouteChildren = { CodeAgentsScoutsSkillNameRoute: CodeAgentsScoutsSkillNameRouteWithChildren, - CodeAgentsScoutsIndexRoute: CodeAgentsScoutsIndexRoute, } const CodeAgentsScoutsRouteWithChildren = diff --git a/packages/ui/src/router/routes/code/agents/scouts.index.tsx b/packages/ui/src/router/routes/code/agents/scouts.index.tsx deleted file mode 100644 index e61ca8e92..000000000 --- a/packages/ui/src/router/routes/code/agents/scouts.index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { ScoutsView } from "@posthog/ui/features/scouts/components/ScoutsView"; -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute("/code/agents/scouts/")({ - component: ScoutsView, -}); From 91bb50d63174434bd6b4180364a1ad9333d5d16d Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 18:55:36 +0100 Subject: [PATCH 04/37] feat(scouts): unify emitted-signal copy and link scout skill to PostHog cloud - All signal counts now read "N signal(s) emitted" (was "0 signals (quiet)" / bare "N signals") across fleet rows and run rows. - Scout detail header links to the skill in PostHog cloud (/skills/) via new skillUrl helper in posthogLinks. --- .../scouts/components/ScoutDetailView.tsx | 29 ++++++++++++++++--- .../scouts/components/ScoutsFleetSection.tsx | 4 +-- packages/ui/src/utils/posthogLinks.ts | 10 +++++++ 3 files changed, 36 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index 661d710dd..bd86f0c73 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -1,4 +1,8 @@ -import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; +import { + ArrowLeftIcon, + ArrowSquareOutIcon, + CompassIcon, +} from "@phosphor-icons/react"; import type { ScoutRun } from "@posthog/api-client/posthog-client"; import { computeScoutRollups, @@ -18,6 +22,7 @@ import { } from "@posthog/core/scouts/scoutRunsWindow"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { skillUrl } from "@posthog/ui/utils/posthogLinks"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; @@ -92,6 +97,7 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { }, [scoutRuns]); const latestVersion = scoutRuns[0]?.skill_version; + const cloudSkillUrl = skillUrl(skillName); const state = config ? deriveScoutRowState(config, rollup, new Date()) : "disabled"; @@ -121,7 +127,22 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { v{latestVersion} ) : null} - {skillName} + + + {skillName} + + {cloudSkillUrl ? ( + + View skill in PostHog + + + ) : null} +
@@ -255,10 +276,10 @@ function ScoutRunListItem({ {emitted > 0 ? ( - {emitted} signal{emitted === 1 ? "" : "s"} + {emitted} signal{emitted === 1 ? "" : "s"} emitted ) : status === "completed" ? ( - quiet + 0 signals emitted ) : null} {run.summary ? ( diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 0902e7806..3bcf255ed 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -233,9 +233,7 @@ function ScoutRowStats({ parts.push(`${rollup.runCount} runs`); parts.push(`${rollup.completedCount} ok / ${rollup.failedCount} failed`); parts.push( - rollup.emittedCount > 0 - ? `${rollup.emittedCount} signal${rollup.emittedCount === 1 ? "" : "s"}` - : "0 signals (quiet)", + `${rollup.emittedCount} signal${rollup.emittedCount === 1 ? "" : "s"} emitted`, ); } diff --git a/packages/ui/src/utils/posthogLinks.ts b/packages/ui/src/utils/posthogLinks.ts index 807629055..38852dee5 100644 --- a/packages/ui/src/utils/posthogLinks.ts +++ b/packages/ui/src/utils/posthogLinks.ts @@ -66,6 +66,16 @@ export function featureFlagsIndexUrl(overrides?: LinkOverrides): string | null { return withProjectId((pid) => `/project/${pid}/feature_flags`, overrides); } +export function skillUrl( + skillName: string, + overrides?: LinkOverrides, +): string | null { + return withProjectId( + (pid) => `/project/${pid}/skills/${encodeURIComponent(skillName)}`, + overrides, + ); +} + export function errorTrackingIssueUrl( issueId: string, overrides?: LinkOverrides, From 3b0a70c4b6eec1876991bb6cf4883d8612d83459 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:02:04 +0100 Subject: [PATCH 05/37] feat(scouts): per-run outcome boxes on fleet rows Each fleet row now shows a strip of small boxes, one per run in the 24h window (oldest left), colored by outcome: iris emitted, green quiet, red failed, amber timed out, blue pulsing running, red pulsing stuck. Hovering shows outcome + duration + relative time; clicking a box opens the run detail (summary, emissions, task log link). - core: ScoutRollup now carries the per-scout runs in timeline order; new deriveRunOutcome/scoutRunOutcomeLabel classifiers with tests. - ui: new ScoutRunBoxes component wired into ScoutRow. --- .../core/src/scouts/scoutPresentation.test.ts | 64 ++++++++++++++++ packages/core/src/scouts/scoutPresentation.ts | 63 ++++++++++++++++ .../scouts/components/ScoutRunBoxes.tsx | 73 +++++++++++++++++++ .../scouts/components/ScoutsFleetSection.tsx | 2 + 4 files changed, 202 insertions(+) create mode 100644 packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx diff --git a/packages/core/src/scouts/scoutPresentation.test.ts b/packages/core/src/scouts/scoutPresentation.test.ts index 81a489898..45bb36443 100644 --- a/packages/core/src/scouts/scoutPresentation.test.ts +++ b/packages/core/src/scouts/scoutPresentation.test.ts @@ -4,6 +4,7 @@ import { computeFleetSummary, computeScoutRollups, deriveRunFailureKind, + deriveRunOutcome, formatRunDuration, formatRunInterval, formatRunIntervalShort, @@ -13,6 +14,7 @@ import { prettifyScoutSkillName, runDurationSeconds, runMatchesFilter, + scoutRunOutcomeLabel, scoutSkillNameFromSlug, scoutSkillSlug, sortConfigsForDisplay, @@ -140,6 +142,63 @@ describe("run status", () => { }); }); +describe("run outcomes", () => { + it("classifies each run into a single outcome", () => { + expect(deriveRunOutcome(makeRun({ emitted_count: 2 }), NOW)).toBe( + "emitted", + ); + expect(deriveRunOutcome(makeRun({ emitted_count: 0 }), NOW)).toBe("quiet"); + expect( + deriveRunOutcome( + makeRun({ status: "failed", completed_at: "2026-06-10T11:00:30Z" }), + NOW, + ), + ).toBe("error"); + expect( + deriveRunOutcome( + makeRun({ status: "failed", completed_at: "2026-06-10T11:30:10Z" }), + NOW, + ), + ).toBe("timed_out"); + expect( + deriveRunOutcome( + makeRun({ + status: "in_progress", + started_at: "2026-06-10T11:55:00Z", + completed_at: null, + }), + NOW, + ), + ).toBe("running"); + expect( + deriveRunOutcome( + makeRun({ + status: "in_progress", + started_at: "2026-06-10T11:20:00Z", + completed_at: null, + }), + NOW, + ), + ).toBe("stuck"); + expect(deriveRunOutcome(makeRun({ status: "queued" }), NOW)).toBe("queued"); + }); + + it("labels outcomes with emitted counts", () => { + expect(scoutRunOutcomeLabel(makeRun({ emitted_count: 1 }), NOW)).toBe( + "1 signal emitted", + ); + expect(scoutRunOutcomeLabel(makeRun({ emitted_count: 0 }), NOW)).toBe( + "0 signals emitted", + ); + expect( + scoutRunOutcomeLabel( + makeRun({ status: "failed", completed_at: "2026-06-10T11:30:10Z" }), + NOW, + ), + ).toBe("timed out"); + }); +}); + describe("run filters", () => { const emitted = makeRun({ emitted_count: 2 }); const quiet = makeRun({ emitted_count: 0 }); @@ -188,6 +247,11 @@ describe("rollups", () => { expect(errorTracking?.latestRun?.run_id).toBe("b"); expect(errorTracking?.runningRun).toBeNull(); expect(rollups.get("signals-scout-logs")?.runningRun?.run_id).toBe("d"); + expect(errorTracking?.runs.map((run) => run.run_id)).toEqual([ + "c", + "a", + "b", + ]); }); it("computes the fleet summary", () => { diff --git a/packages/core/src/scouts/scoutPresentation.ts b/packages/core/src/scouts/scoutPresentation.ts index 7ee405e3b..a2888a1ed 100644 --- a/packages/core/src/scouts/scoutPresentation.ts +++ b/packages/core/src/scouts/scoutPresentation.ts @@ -122,6 +122,58 @@ export function isRunStuck(run: ScoutRun, now: Date): boolean { return duration !== null && duration >= STUCK_THRESHOLD_SECONDS; } +/** + * Single classification for "how did this run go", combining status, failure + * kind, and emission count. Drives the per-run outcome boxes and tooltips. + */ +export type ScoutRunOutcome = + | "emitted" + | "quiet" + | "error" + | "timed_out" + | "running" + | "stuck" + | "queued" + | "unknown"; + +export function deriveRunOutcome(run: ScoutRun, now: Date): ScoutRunOutcome { + const status = normalizeRunStatus(run.status); + if (status === "completed") { + return (run.emitted_count ?? 0) > 0 ? "emitted" : "quiet"; + } + if (status === "failed") { + return deriveRunFailureKind(run, now) === "timed_out" + ? "timed_out" + : "error"; + } + if (status === "running") return isRunStuck(run, now) ? "stuck" : "running"; + if (status === "queued") return "queued"; + return "unknown"; +} + +export function scoutRunOutcomeLabel(run: ScoutRun, now: Date): string { + switch (deriveRunOutcome(run, now)) { + case "emitted": { + const count = run.emitted_count ?? 0; + return `${count} signal${count === 1 ? "" : "s"} emitted`; + } + case "quiet": + return "0 signals emitted"; + case "error": + return "failed"; + case "timed_out": + return "timed out"; + case "running": + return "running now"; + case "stuck": + return "running past the deadline – may be stuck"; + case "queued": + return "queued"; + case "unknown": + return run.status; + } +} + export type ScoutRunFilter = "all" | "emitted" | "quiet" | "failed"; export function runMatchesFilter( @@ -148,6 +200,8 @@ export interface ScoutRollup { emittedCount: number; latestRun: ScoutRun | null; runningRun: ScoutRun | null; + /** This scout's runs in the window, oldest first (timeline order). */ + runs: ScoutRun[]; } function emptyRollup(): ScoutRollup { @@ -158,6 +212,7 @@ function emptyRollup(): ScoutRollup { emittedCount: 0, latestRun: null, runningRun: null, + runs: [], }; } @@ -182,6 +237,7 @@ export function computeScoutRollups( if (status === "completed") rollup.completedCount += 1; if (status === "failed") rollup.failedCount += 1; rollup.emittedCount += run.emitted_count ?? 0; + rollup.runs.push(run); const startedAt = run.started_at ? new Date(run.started_at).getTime() : 0; const latestStartedAt = rollup.latestRun?.started_at ? new Date(rollup.latestRun.started_at).getTime() @@ -189,6 +245,13 @@ export function computeScoutRollups( if (startedAt > latestStartedAt) rollup.latestRun = run; if (status === "running" && !rollup.runningRun) rollup.runningRun = run; } + for (const rollup of rollups.values()) { + rollup.runs.sort((a, b) => { + const aStarted = a.started_at ? new Date(a.started_at).getTime() : 0; + const bStarted = b.started_at ? new Date(b.started_at).getTime() : 0; + return aStarted - bStarted; + }); + } return rollups; } diff --git a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx new file mode 100644 index 000000000..524f16359 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx @@ -0,0 +1,73 @@ +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { + deriveRunOutcome, + formatRunDuration, + runDurationSeconds, + type ScoutRunOutcome, + scoutRunOutcomeLabel, + scoutSkillSlug, +} from "@posthog/core/scouts/scoutPresentation"; +import { formatRelativeTimeLong } from "@posthog/shared"; +import { Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; + +const OUTCOME_BOX_CLASS: Record = { + emitted: "bg-(--iris-9)", + quiet: "bg-(--green-7)", + error: "bg-(--red-9)", + timed_out: "bg-(--amber-9)", + running: "bg-(--blue-9) animate-pulse", + stuck: "bg-(--red-9) animate-pulse", + queued: "bg-(--gray-6)", + unknown: "bg-(--gray-6)", +}; + +const MAX_BOXES = 24; + +function runTooltip(run: ScoutRun, now: Date): string { + const parts = [scoutRunOutcomeLabel(run, now)]; + const duration = formatRunDuration(runDurationSeconds(run, now)); + if (duration) parts.push(duration); + if (run.started_at) { + parts.push(formatRelativeTimeLong(new Date(run.started_at).getTime())); + } + return parts.join(" · "); +} + +/** + * One small box per run in the visible window, oldest on the left. Each box + * links to the run detail (status, summary, emissions, task log link). + */ +export function ScoutRunBoxes({ runs }: { runs: ScoutRun[] }) { + if (runs.length === 0) return null; + const visible = runs.slice(-MAX_BOXES); + const hidden = runs.length - visible.length; + const now = new Date(); + + return ( + + {hidden > 0 ? ( + +{hidden} + ) : null} + + {visible.map((run) => { + const outcome = deriveRunOutcome(run, now); + const tooltip = runTooltip(run, now); + return ( + + + + ); + })} + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 3bcf255ed..6c3f31061 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -27,6 +27,7 @@ import { ScoutStatusDot, } from "./ScoutBadges"; import { ScoutConfigControls } from "./ScoutConfigControls"; +import { ScoutRunBoxes } from "./ScoutRunBoxes"; /** * Expandable scout fleet manager for the agents config page. Collapsed it is @@ -212,6 +213,7 @@ function ScoutRow({ /> + ); From c1d5c5ec88b0435edcf8e51fb745a4713b6e567a Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:04:47 +0100 Subject: [PATCH 06/37] feat(scouts): per-row settings gear with inline config form Fleet rows now show only the enable switch plus a gear toggle; mode (live/dry-run) and cadence move into a labeled form that expands under the row. The detail header keeps the full horizontal controls strip. New ScoutEnabledSwitch + ScoutConfigForm exports alongside ScoutConfigControls. --- .../scouts/components/ScoutConfigControls.tsx | 101 ++++++++++++++---- .../scouts/components/ScoutsFleetSection.tsx | 84 +++++++++------ 2 files changed, 136 insertions(+), 49 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx index d2eb52937..77e733b07 100644 --- a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx +++ b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx @@ -4,7 +4,7 @@ import { RUN_INTERVAL_OPTIONS, } from "@posthog/core/scouts/scoutPresentation"; import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; -import { Flex, Switch, Tooltip } from "@radix-ui/themes"; +import { Flex, Switch, Text, Tooltip } from "@radix-ui/themes"; import { useMemo } from "react"; import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; @@ -18,16 +18,8 @@ interface ScoutConfigControlsProps { onUpdate: (configId: string, updates: ScoutConfigUpdate) => void; } -/** - * The three per-scout controls: live vs dry-run, cadence, and on/off. - * Used inline on fleet rows and in the scout detail header so config never - * requires leaving the current view. - */ -export function ScoutConfigControls({ - config, - onUpdate, -}: ScoutConfigControlsProps) { - const intervalOptions = useMemo(() => { +function useIntervalOptions(config: ScoutConfig) { + return useMemo(() => { const options = RUN_INTERVAL_OPTIONS.map((option) => ({ value: String(option.minutes), label: option.label, @@ -44,6 +36,34 @@ export function ScoutConfigControls({ } return options; }, [config.run_interval_minutes]); +} + +export function ScoutEnabledSwitch({ + config, + onUpdate, +}: ScoutConfigControlsProps) { + return ( + + onUpdate(config.id, { enabled: checked })} + aria-label={`${config.skill_name} enabled`} + /> + + ); +} + +/** + * The three per-scout controls in one horizontal strip: live vs dry-run, + * cadence, and on/off. Used in the scout detail header where there is room. + * Fleet rows show only the switch plus a gear that expands ScoutConfigForm. + */ +export function ScoutConfigControls({ + config, + onUpdate, +}: ScoutConfigControlsProps) { + const intervalOptions = useIntervalOptions(config); return ( @@ -71,16 +91,59 @@ export function ScoutConfigControls({ onUpdate(config.id, { run_interval_minutes: Number(value) }) } /> - - - onUpdate(config.id, { enabled: checked }) + + + ); +} + +/** + * Labeled settings form for one scout, shown when a fleet row's gear is + * toggled open. Everything except enablement, which stays on the row. + */ +export function ScoutConfigForm({ + config, + onUpdate, +}: ScoutConfigControlsProps) { + const intervalOptions = useIntervalOptions(config); + + return ( + + + + Mode + + Dry run executes the scout but holds back its findings + + + + onUpdate(config.id, { emit: value === "live" }) } - aria-label={`${config.skill_name} enabled`} /> - + + + + Cadence + + How often the scout is dispatched + + + + onUpdate(config.id, { run_interval_minutes: Number(value) }) + } + /> + ); } diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 6c3f31061..3021c1b1a 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,4 +1,4 @@ -import { CaretDownIcon, CompassIcon } from "@phosphor-icons/react"; +import { CaretDownIcon, CompassIcon, GearSixIcon } from "@phosphor-icons/react"; import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, @@ -14,7 +14,7 @@ import { scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { Box, Flex, Text } from "@radix-ui/themes"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; @@ -26,7 +26,7 @@ import { ScoutOriginBadge, ScoutStatusDot, } from "./ScoutBadges"; -import { ScoutConfigControls } from "./ScoutConfigControls"; +import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; import { ScoutRunBoxes } from "./ScoutRunBoxes"; /** @@ -179,42 +179,66 @@ function ScoutRow({ }) { const now = new Date(); const state = deriveScoutRowState(config, rollup, now); + const [settingsOpen, setSettingsOpen] = useState(false); return ( - - - - - - {prettifyScoutSkillName(config.skill_name)} - - - - - {formatRunIntervalShort(config.run_interval_minutes)} - + + + + + + + {prettifyScoutSkillName(config.skill_name)} + + + + + {formatRunIntervalShort(config.run_interval_minutes)} + + + - + + + + + + + - - - + + {settingsOpen ? ( + + + + ) : null} ); } From 9e356b46e33f3ac2589cb1356d8163e6fceab497 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:08:45 +0100 Subject: [PATCH 07/37] feat(scouts): single-line fleet rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the per-row stats subtitle (last ran / run counts / emitted) – the status dot, run boxes, and their tooltips already carry it. A row is now: dot, name, badges, cadence, run boxes, switch, gear. --- .../scouts/components/ScoutsFleetSection.tsx | 75 +++---------------- 1 file changed, 10 insertions(+), 65 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 3021c1b1a..f5ef39be9 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,5 +1,5 @@ import { CaretDownIcon, CompassIcon, GearSixIcon } from "@phosphor-icons/react"; -import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client"; +import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, computeScoutRollups, @@ -192,27 +192,17 @@ function ScoutRow({ - - - - {prettifyScoutSkillName(config.skill_name)} - - - - - {formatRunIntervalShort(config.run_interval_minutes)} - - - - + + {prettifyScoutSkillName(config.skill_name)} + + + + + {formatRunIntervalShort(config.run_interval_minutes)} + @@ -243,51 +233,6 @@ function ScoutRow({ ); } -function ScoutRowStats({ - config, - rollup, - state, - runningRun, -}: { - config: ScoutConfig; - rollup: ScoutRollup | undefined; - state: string; - runningRun: ScoutRun | null; -}) { - const parts: string[] = []; - if (rollup && rollup.runCount > 0) { - parts.push(`${rollup.runCount} runs`); - parts.push(`${rollup.completedCount} ok / ${rollup.failedCount} failed`); - parts.push( - `${rollup.emittedCount} signal${rollup.emittedCount === 1 ? "" : "s"} emitted`, - ); - } - - return ( - - {state === "running" && runningRun ? ( - running now - ) : state === "stuck" ? ( - - running past the deadline – may be stuck - - ) : config.last_run_at ? ( - - last ran - - - ) : ( - never run - )} - {parts.length > 0 && ( - - · {parts.join(" · ")} - - )} - - ); -} - function ScoutsEmptyState() { return ( Date: Wed, 10 Jun 2026 19:10:29 +0100 Subject: [PATCH 08/37] feat(scouts): cloud skill link beside scout name on fleet rows Small external-link icon next to the name opening the skill in PostHog cloud (/skills/). Revealed on row hover to keep the single-line rows quiet; sits outside the router Link to avoid a nested anchor. --- .../scouts/components/ScoutsFleetSection.tsx | 46 ++++++++++++++----- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index f5ef39be9..44717d7c9 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,4 +1,9 @@ -import { CaretDownIcon, CompassIcon, GearSixIcon } from "@phosphor-icons/react"; +import { + ArrowSquareOutIcon, + CaretDownIcon, + CompassIcon, + GearSixIcon, +} from "@phosphor-icons/react"; import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, @@ -14,6 +19,7 @@ import { scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { skillUrl } from "@posthog/ui/utils/posthogLinks"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; @@ -180,30 +186,46 @@ function ScoutRow({ const now = new Date(); const state = deriveScoutRowState(config, rollup, now); const [settingsOpen, setSettingsOpen] = useState(false); + const cloudSkillUrl = skillUrl(config.skill_name); return ( - - - - {prettifyScoutSkillName(config.skill_name)} - + + + + + {prettifyScoutSkillName(config.skill_name)} + + + {cloudSkillUrl ? ( + + + + + + ) : null} {formatRunIntervalShort(config.run_interval_minutes)} - + From d9b7ebde72f3c4aec47895467a08c3fb0f45e8f4 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:13:41 +0100 Subject: [PATCH 09/37] feat(scouts): show emitted-signal count on fleet rows when nonzero MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "N signals emitted" after the cadence chip, only when the scout emitted at least one signal in the 24h window – quiet scouts stay clean. --- .../src/features/scouts/components/ScoutsFleetSection.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 44717d7c9..8d433149f 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -225,6 +225,12 @@ function ScoutRow({ {formatRunIntervalShort(config.run_interval_minutes)} + {rollup && rollup.emittedCount > 0 ? ( + + · {rollup.emittedCount} signal + {rollup.emittedCount === 1 ? "" : "s"} emitted + + ) : null} From bb3fde13712abf1d168fcb97d1eb7b14212e658d Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:18:00 +0100 Subject: [PATCH 10/37] feat(scouts): shared ScoutRowCard on fleet list and detail screen Extract the fleet row into ScoutRowCard and reuse it as the header card on the scout detail screen (linkToDetail=false there), replacing the old title block + Configuration card so the two surfaces stay identical. Also drop the quiet-run sentence from the Scouts subsection description. --- .../components/ConfigureAgentsSection.tsx | 2 +- .../scouts/components/ScoutDetailView.tsx | 81 ++--------- .../scouts/components/ScoutRowCard.tsx | 128 ++++++++++++++++++ .../scouts/components/ScoutsFleetSection.tsx | 118 +--------------- 4 files changed, 144 insertions(+), 185 deletions(-) create mode 100644 packages/ui/src/features/scouts/components/ScoutRowCard.tsx diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 6720a23eb..42e98f46e 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -108,7 +108,7 @@ export function ConfigureAgentsSection() { diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index bd86f0c73..9c7a44c99 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -1,8 +1,4 @@ -import { - ArrowLeftIcon, - ArrowSquareOutIcon, - CompassIcon, -} from "@phosphor-icons/react"; +import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; import type { ScoutRun } from "@posthog/api-client/posthog-client"; import { computeScoutRollups, @@ -22,20 +18,13 @@ import { } from "@posthog/core/scouts/scoutRunsWindow"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { skillUrl } from "@posthog/ui/utils/posthogLinks"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; -import { - DryRunBadge, - deriveScoutRowState, - ScoutOriginBadge, - ScoutStatusDot, -} from "./ScoutBadges"; -import { ScoutConfigControls } from "./ScoutConfigControls"; +import { ScoutRowCard } from "./ScoutRowCard"; const FILTERS: { value: ScoutRunFilter; label: string }[] = [ { value: "all", label: "All" }, @@ -96,12 +85,6 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { return counts; }, [scoutRuns]); - const latestVersion = scoutRuns[0]?.skill_version; - const cloudSkillUrl = skillUrl(skillName); - const state = config - ? deriveScoutRowState(config, rollup, new Date()) - : "disabled"; - return ( Agents - - - - {displayName} - - - {config ? : null} - {latestVersion !== undefined ? ( - v{latestVersion} - ) : null} - - - - {skillName} - - {cloudSkillUrl ? ( - - View skill in PostHog - - - ) : null} - + + {displayName} +
{config ? ( - - - - Configuration - - - {config.last_run_at ? ( - <> - Last dispatched{" "} - - - ) : ( - "Never dispatched" - )} - - - - + ) : ( No config found for this scout on the current project. diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx new file mode 100644 index 000000000..b6d90a671 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -0,0 +1,128 @@ +import { ArrowSquareOutIcon, GearSixIcon } from "@phosphor-icons/react"; +import type { ScoutConfig } from "@posthog/api-client/posthog-client"; +import { + formatRunIntervalShort, + prettifyScoutSkillName, + type ScoutRollup, + scoutSkillSlug, +} from "@posthog/core/scouts/scoutPresentation"; +import { skillUrl } from "@posthog/ui/utils/posthogLinks"; +import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useState } from "react"; +import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; +import { + DryRunBadge, + deriveScoutRowState, + ScoutOriginBadge, + ScoutStatusDot, +} from "./ScoutBadges"; +import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; +import { ScoutRunBoxes } from "./ScoutRunBoxes"; + +/** + * The one scout card: dot, name, badges, cadence, emitted count, run boxes, + * enable switch, and a gear that expands the settings form. Used both as the + * fleet list row and as the header card on the scout detail screen, so the + * two surfaces always look and behave the same. + */ +export function ScoutRowCard({ + config, + rollup, + onUpdate, + linkToDetail = true, +}: { + config: ScoutConfig; + rollup: ScoutRollup | undefined; + onUpdate: (configId: string, updates: ScoutConfigUpdate) => void; + linkToDetail?: boolean; +}) { + const now = new Date(); + const state = deriveScoutRowState(config, rollup, now); + const [settingsOpen, setSettingsOpen] = useState(false); + const cloudSkillUrl = skillUrl(config.skill_name); + + const title = ( + <> + + + {prettifyScoutSkillName(config.skill_name)} + + + ); + + return ( + + + + {linkToDetail ? ( + + {title} + + ) : ( + + {title} + + )} + {cloudSkillUrl ? ( + + + + + + ) : null} + + + + {formatRunIntervalShort(config.run_interval_minutes)} + + {rollup && rollup.emittedCount > 0 ? ( + + · {rollup.emittedCount} signal + {rollup.emittedCount === 1 ? "" : "s"} emitted + + ) : null} + + + + + + + + + + {settingsOpen ? ( + + + + ) : null} + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 8d433149f..b610fbd37 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,17 +1,8 @@ -import { - ArrowSquareOutIcon, - CaretDownIcon, - CompassIcon, - GearSixIcon, -} from "@phosphor-icons/react"; +import { CaretDownIcon, CompassIcon } from "@phosphor-icons/react"; import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, computeScoutRollups, - formatRunIntervalShort, - prettifyScoutSkillName, - type ScoutRollup, - scoutSkillSlug, sortConfigsForDisplay, } from "@posthog/core/scouts/scoutPresentation"; import { @@ -19,21 +10,12 @@ import { scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { skillUrl } from "@posthog/ui/utils/posthogLinks"; -import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; +import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; -import { - DryRunBadge, - deriveScoutRowState, - ScoutOriginBadge, - ScoutStatusDot, -} from "./ScoutBadges"; -import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; -import { ScoutRunBoxes } from "./ScoutRunBoxes"; +import { ScoutRowCard } from "./ScoutRowCard"; /** * Expandable scout fleet manager for the agents config page. Collapsed it is @@ -149,7 +131,7 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
{visibleConfigs.map((config) => ( - ["updateConfig"] - >[1], - ) => void; -}) { - const now = new Date(); - const state = deriveScoutRowState(config, rollup, now); - const [settingsOpen, setSettingsOpen] = useState(false); - const cloudSkillUrl = skillUrl(config.skill_name); - - return ( - - - - - - - {prettifyScoutSkillName(config.skill_name)} - - - {cloudSkillUrl ? ( - - - - - - ) : null} - - - - {formatRunIntervalShort(config.run_interval_minutes)} - - {rollup && rollup.emittedCount > 0 ? ( - - · {rollup.emittedCount} signal - {rollup.emittedCount === 1 ? "" : "s"} emitted - - ) : null} - - - - - - - - - - {settingsOpen ? ( - - - - ) : null} - - ); -} - function ScoutsEmptyState() { return ( Date: Wed, 10 Jun 2026 19:20:17 +0100 Subject: [PATCH 11/37] feat(scouts): run boxes open the task run in PostHog cloud Boxes with a task_url now link straight to the backing task run in the browser, with the tooltip calling it out ('open task run in PostHog'). Runs without a task link keep the in-app run detail fallback. --- .../scouts/components/ScoutRunBoxes.tsx | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx index 524f16359..89d1911f1 100644 --- a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx @@ -8,6 +8,7 @@ import { scoutSkillSlug, } from "@posthog/core/scouts/scoutPresentation"; import { formatRelativeTimeLong } from "@posthog/shared"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; @@ -23,6 +24,8 @@ const OUTCOME_BOX_CLASS: Record = { }; const MAX_BOXES = 24; +const BOX_CLASS = + "block h-3 w-2 rounded-[2px] transition-transform duration-100 hover:scale-y-125 hover:ring-(--gray-8) hover:ring-1"; function runTooltip(run: ScoutRun, now: Date): string { const parts = [scoutRunOutcomeLabel(run, now)]; @@ -36,7 +39,8 @@ function runTooltip(run: ScoutRun, now: Date): string { /** * One small box per run in the visible window, oldest on the left. Each box - * links to the run detail (status, summary, emissions, task log link). + * opens the backing task run in PostHog cloud; runs without a task link fall + * back to the in-app run detail. */ export function ScoutRunBoxes({ runs }: { runs: ScoutRun[] }) { if (runs.length === 0) return null; @@ -52,7 +56,24 @@ export function ScoutRunBoxes({ runs }: { runs: ScoutRun[] }) { {visible.map((run) => { const outcome = deriveRunOutcome(run, now); - const tooltip = runTooltip(run, now); + const boxClass = `${BOX_CLASS} ${OUTCOME_BOX_CLASS[outcome]}`; + const taskRunUrl = run.task_url ? getPostHogUrl(run.task_url) : null; + if (taskRunUrl) { + const tooltip = `${runTooltip(run, now)} · open task run in PostHog`; + return ( + + + Run {tooltip} + + + ); + } + const tooltip = `${runTooltip(run, now)} · open run detail`; return ( ); From a50ff5f399e85662b5844ade1daa0cdf65aa9dff Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:26:42 +0100 Subject: [PATCH 12/37] feat(scouts): fleet summary shows emit rate and tighter window label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "41 signals emitted (20%) · last 24h" – emitRate is the share of window runs that emitted at least one signal; window label loses the space ("24 h" → "24h") and its parentheses. --- packages/core/src/scouts/scoutPresentation.test.ts | 4 +++- packages/core/src/scouts/scoutPresentation.ts | 9 +++++++++ packages/core/src/scouts/scoutRunsWindow.test.ts | 8 +++----- packages/core/src/scouts/scoutRunsWindow.ts | 4 ++-- .../features/scouts/components/ScoutsFleetSection.tsx | 5 ++++- 5 files changed, 21 insertions(+), 9 deletions(-) diff --git a/packages/core/src/scouts/scoutPresentation.test.ts b/packages/core/src/scouts/scoutPresentation.test.ts index 45bb36443..822a2cf68 100644 --- a/packages/core/src/scouts/scoutPresentation.test.ts +++ b/packages/core/src/scouts/scoutPresentation.test.ts @@ -275,11 +275,13 @@ describe("rollups", () => { emittedCount: 2, }); expect(summary.successRate).toBe(0.5); + expect(summary.emitRate).toBe(0.5); }); - it("returns a null success rate with no finished runs", () => { + it("returns null rates with no runs", () => { const summary = computeFleetSummary([], computeScoutRollups([])); expect(summary.successRate).toBeNull(); + expect(summary.emitRate).toBeNull(); }); }); diff --git a/packages/core/src/scouts/scoutPresentation.ts b/packages/core/src/scouts/scoutPresentation.ts index a2888a1ed..e46514a7d 100644 --- a/packages/core/src/scouts/scoutPresentation.ts +++ b/packages/core/src/scouts/scoutPresentation.ts @@ -262,6 +262,8 @@ export interface FleetSummary { emittedCount: number; /** Completed / (completed + failed) over the visible window, or null when no finished runs. */ successRate: number | null; + /** Share of runs in the window that emitted at least one signal, or null when no runs. */ + emitRate: number | null; } export function computeFleetSummary( @@ -272,11 +274,17 @@ export function computeFleetSummary( let emittedCount = 0; let completedCount = 0; let failedCount = 0; + let runCount = 0; + let emittedRunCount = 0; for (const rollup of rollups.values()) { if (rollup.runningRun) runningCount += 1; emittedCount += rollup.emittedCount; completedCount += rollup.completedCount; failedCount += rollup.failedCount; + runCount += rollup.runCount; + for (const run of rollup.runs) { + if ((run.emitted_count ?? 0) > 0) emittedRunCount += 1; + } } const finished = completedCount + failedCount; return { @@ -285,6 +293,7 @@ export function computeFleetSummary( runningCount, emittedCount, successRate: finished > 0 ? completedCount / finished : null, + emitRate: runCount > 0 ? emittedRunCount / runCount : null, }; } diff --git a/packages/core/src/scouts/scoutRunsWindow.test.ts b/packages/core/src/scouts/scoutRunsWindow.test.ts index b954556d1..c65fc10d6 100644 --- a/packages/core/src/scouts/scoutRunsWindow.test.ts +++ b/packages/core/src/scouts/scoutRunsWindow.test.ts @@ -116,12 +116,10 @@ describe("fetchScoutRunsWindow", () => { describe("scoutRunsWindowLabel", () => { it("names the window and flags truncation", () => { - expect(scoutRunsWindowLabel({ runs: [], complete: true })).toBe( - "last 24 h", - ); + expect(scoutRunsWindowLabel({ runs: [], complete: true })).toBe("last 24h"); expect(scoutRunsWindowLabel({ runs: [], complete: false })).toBe( - "last 24 h · truncated", + "last 24h · truncated", ); - expect(scoutRunsWindowLabel(undefined)).toBe("last 24 h"); + expect(scoutRunsWindowLabel(undefined)).toBe("last 24h"); }); }); diff --git a/packages/core/src/scouts/scoutRunsWindow.ts b/packages/core/src/scouts/scoutRunsWindow.ts index 34a5d8bcd..b4675bb0a 100644 --- a/packages/core/src/scouts/scoutRunsWindow.ts +++ b/packages/core/src/scouts/scoutRunsWindow.ts @@ -29,9 +29,9 @@ export interface ScoutRunsWindow { complete: boolean; } -/** Label for stats derived from a window, e.g. "last 24 h". */ +/** Label for stats derived from a window, e.g. "last 24h". */ export function scoutRunsWindowLabel(window?: ScoutRunsWindow): string { - const base = `last ${SCOUT_RUNS_WINDOW_HOURS} h`; + const base = `last ${SCOUT_RUNS_WINDOW_HOURS}h`; return window && !window.complete ? `${base} · truncated` : base; } diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index b610fbd37..a414e10af 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -112,9 +112,12 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { ? ` · ${Math.round(summary.successRate * 100)}% success` : ""} {` · ${summary.emittedCount} signal${summary.emittedCount === 1 ? "" : "s"} emitted`} + {summary.emitRate !== null + ? ` (${Math.round(summary.emitRate * 100)}%)` + : ""} {" "} - ({scoutRunsWindowLabel(runsWindow)}) + · {scoutRunsWindowLabel(runsWindow)} From 14d0b6775ddfde04cda42bcb5171fdf4693458f2 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 19:56:19 +0100 Subject: [PATCH 13/37] feat(scouts): signals section on scout detail above the run list --- .../scouts/components/ScoutDetailView.tsx | 8 ++ .../scouts/components/ScoutEmissionCard.tsx | 20 +++-- .../scouts/components/ScoutSignalsSection.tsx | 81 +++++++++++++++++++ 3 files changed, 104 insertions(+), 5 deletions(-) create mode 100644 packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index 9c7a44c99..b4625e9df 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -25,6 +25,7 @@ import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; import { ScoutRowCard } from "./ScoutRowCard"; +import { ScoutSignalsSection } from "./ScoutSignalsSection"; const FILTERS: { value: ScoutRunFilter; label: string }[] = [ { value: "all", label: "All" }, @@ -129,6 +130,13 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { ) : null} + + diff --git a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx index 9aa6a4361..2a8eee5c0 100644 --- a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx @@ -3,9 +3,17 @@ import type { ScoutEmission } from "@posthog/api-client/posthog-client"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Box, Flex, Text } from "@radix-ui/themes"; +import type { ReactNode } from "react"; import { SeverityBadge } from "./ScoutBadges"; -export function ScoutEmissionCard({ emission }: { emission: ScoutEmission }) { +export function ScoutEmissionCard({ + emission, + footerEnd, +}: { + emission: ScoutEmission; + /** Replaces the default pipeline note at the footer's right edge. */ + footerEnd?: ReactNode; +}) { return ( @@ -30,10 +38,12 @@ export function ScoutEmissionCard({ emission }: { emission: ScoutEmission }) { > {emission.finding_id} - - Sent to the signals pipeline – report assignment isn't traceable - here yet - + {footerEnd ?? ( + + Sent to the signals pipeline – report assignment isn't + traceable here yet + + )} ); diff --git a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx new file mode 100644 index 000000000..53b5f3c19 --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx @@ -0,0 +1,81 @@ +import { ArrowRightIcon } from "@phosphor-icons/react"; +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions"; +import { ScoutEmissionCard } from "./ScoutEmissionCard"; + +/** + * The signals this scout emitted in the runs window, newest first. Emissions + * are only fetchable per run, so each emitted run gets its own child query — + * emitted runs are rare, so this stays a handful of requests at most. + */ +export function ScoutSignalsSection({ + runs, + skillSlug, + windowLabel, + loading, +}: { + runs: ScoutRun[]; + skillSlug: string; + windowLabel: string; + loading: boolean; +}) { + const emittedRuns = runs.filter((run) => (run.emitted_count ?? 0) > 0); + + return ( + + Signals + {loading ? ( + + ) : emittedRuns.length === 0 ? ( + + No signals emitted in the {windowLabel}. + + ) : ( + + {emittedRuns.map((run) => ( + + ))} + + )} + + ); +} + +function RunEmissions({ + run, + skillSlug, +}: { + run: ScoutRun; + skillSlug: string; +}) { + const { data: emissions, isLoading } = useScoutRunEmissions(run.run_id); + + if (isLoading) { + return ( + + ); + } + + return ( + + {(emissions ?? []).map((emission) => ( + + View run + + + } + /> + ))} + + ); +} From 5cd6ac8ae6516d32d42a81f7beb3113f60641e69 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 20:23:49 +0100 Subject: [PATCH 14/37] feat(scouts): emission cards collapse to a two-line preview --- .../scouts/components/ScoutEmissionCard.tsx | 60 ++++++++++++------- .../scouts/components/ScoutRunDetailView.tsx | 1 + 2 files changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx index 2a8eee5c0..6bf8014fb 100644 --- a/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutEmissionCard.tsx @@ -1,22 +1,34 @@ -import { CompassIcon } from "@phosphor-icons/react"; +import { CaretRightIcon, CompassIcon } from "@phosphor-icons/react"; import type { ScoutEmission } from "@posthog/api-client/posthog-client"; import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Box, Flex, Text } from "@radix-ui/themes"; -import type { ReactNode } from "react"; +import { type ReactNode, useState } from "react"; import { SeverityBadge } from "./ScoutBadges"; export function ScoutEmissionCard({ emission, footerEnd, + defaultExpanded = false, }: { emission: ScoutEmission; /** Replaces the default pipeline note at the footer's right edge. */ footerEnd?: ReactNode; + defaultExpanded?: boolean; }) { + const [expanded, setExpanded] = useState(defaultExpanded); return ( - + + - - {emission.finding_id} - - {footerEnd ?? ( - - Sent to the signals pipeline – report assignment isn't - traceable here yet - - )} - + {expanded ? ( + + {emission.finding_id} + + {footerEnd ?? ( + + Sent to the signals pipeline – report assignment isn't + traceable here yet + + )} + + ) : null} ); } diff --git a/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx index 8ef55aeb0..cb7086862 100644 --- a/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx @@ -171,6 +171,7 @@ export function ScoutRunDetailView({ ))} From 632b9ab130ff12017d2fc3bbfad458ae6f4c6a4f Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 20:26:31 +0100 Subject: [PATCH 15/37] feat(scouts): run rows collapse-expand like emission cards --- .../scouts/components/ScoutDetailView.tsx | 108 ++++++++++++------ 1 file changed, 71 insertions(+), 37 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index b4625e9df..481afd742 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -1,4 +1,9 @@ -import { ArrowLeftIcon, CompassIcon } from "@phosphor-icons/react"; +import { + ArrowLeftIcon, + ArrowRightIcon, + CaretRightIcon, + CompassIcon, +} from "@phosphor-icons/react"; import type { ScoutRun } from "@posthog/api-client/posthog-client"; import { computeScoutRollups, @@ -16,6 +21,7 @@ import { SCOUT_RUNS_WINDOW_HOURS, scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; +import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; @@ -198,6 +204,7 @@ function ScoutRunListItem({ run: ScoutRun; skillSlug: string; }) { + const [expanded, setExpanded] = useState(false); const now = new Date(); const status = normalizeRunStatus(run.status); const failureKind = deriveRunFailureKind(run, now); @@ -205,44 +212,71 @@ function ScoutRunListItem({ const emitted = run.emitted_count ?? 0; return ( - - - - - - {duration ? ( - · {duration} - ) : null} - {failureKind ? ( - - · {failureKind === "timed_out" ? "timed out" : "failed"} - - ) : null} - - {emitted > 0 ? ( - - {emitted} signal{emitted === 1 ? "" : "s"} emitted - - ) : status === "completed" ? ( - 0 signals emitted - ) : null} - - {run.summary ? ( - - {run.summary} - - ) : status === "failed" ? ( - - No summary – the run ended before writing its close-out. Open the - run for the task log. + + + {run.summary ? ( + + + + ) : status === "failed" ? ( + + No summary – the run ended before writing its close-out. Open the run + for the task log. + + ) : null} + {expanded ? ( + + {run.run_id} + + + View run + + + + ) : null} + ); } From 5162ee875ef873db37b12dc2082076248bed48a2 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 20:31:42 +0100 Subject: [PATCH 16/37] feat(scouts): remove in-app run detail page in favor of cloud task runs --- .../scouts/components/ScoutDetailView.tsx | 47 ++- .../scouts/components/ScoutRunBoxes.tsx | 20 +- .../scouts/components/ScoutRunDetailView.tsx | 293 ------------------ .../scouts/components/ScoutSignalsSection.tsx | 36 +-- .../features/scouts/hooks/scoutQueryKeys.ts | 4 - .../src/features/scouts/hooks/useScoutRun.ts | 23 -- .../scouts/hooks/useScoutScratchpad.ts | 22 -- packages/ui/src/router/routeTree.gen.ts | 23 -- .../agents/scouts.$skillName.runs.$runId.tsx | 13 - 9 files changed, 44 insertions(+), 437 deletions(-) delete mode 100644 packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx delete mode 100644 packages/ui/src/features/scouts/hooks/useScoutRun.ts delete mode 100644 packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts delete mode 100644 packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index 481afd742..14718c807 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -1,6 +1,6 @@ import { ArrowLeftIcon, - ArrowRightIcon, + ArrowSquareOutIcon, CaretRightIcon, CompassIcon, } from "@phosphor-icons/react"; @@ -15,7 +15,6 @@ import { runMatchesFilter, type ScoutRunFilter, scoutSkillNameFromSlug, - scoutSkillSlug, } from "@posthog/core/scouts/scoutPresentation"; import { SCOUT_RUNS_WINDOW_HOURS, @@ -24,6 +23,7 @@ import { import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; @@ -138,7 +138,6 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { @@ -176,11 +175,7 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { ) : ( {filteredRuns.map((run) => ( - + ))} )} @@ -197,14 +192,9 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { ); } -function ScoutRunListItem({ - run, - skillSlug, -}: { - run: ScoutRun; - skillSlug: string; -}) { +function ScoutRunListItem({ run }: { run: ScoutRun }) { const [expanded, setExpanded] = useState(false); + const taskRunUrl = run.task_url ? getPostHogUrl(run.task_url) : null; const now = new Date(); const status = normalizeRunStatus(run.status); const failureKind = deriveRunFailureKind(run, now); @@ -252,8 +242,8 @@ function ScoutRunListItem({ ) : status === "failed" ? ( - No summary – the run ended before writing its close-out. Open the run - for the task log. + No summary – the run ended before writing its close-out. The task run + in PostHog is the only diagnostic. ) : null} {expanded ? ( @@ -266,14 +256,21 @@ function ScoutRunListItem({ > {run.run_id} - - View run - - + {taskRunUrl ? ( + + Open task run + + + ) : ( + + No task link available + + )} ) : null} diff --git a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx index 89d1911f1..4d9673981 100644 --- a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx @@ -5,12 +5,10 @@ import { runDurationSeconds, type ScoutRunOutcome, scoutRunOutcomeLabel, - scoutSkillSlug, } from "@posthog/core/scouts/scoutPresentation"; import { formatRelativeTimeLong } from "@posthog/shared"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; const OUTCOME_BOX_CLASS: Record = { emitted: "bg-(--iris-9)", @@ -39,8 +37,8 @@ function runTooltip(run: ScoutRun, now: Date): string { /** * One small box per run in the visible window, oldest on the left. Each box - * opens the backing task run in PostHog cloud; runs without a task link fall - * back to the in-app run detail. + * opens the backing task run in PostHog cloud; runs without a task link are + * tooltip-only. */ export function ScoutRunBoxes({ runs }: { runs: ScoutRun[] }) { if (runs.length === 0) return null; @@ -73,18 +71,12 @@ export function ScoutRunBoxes({ runs }: { runs: ScoutRun[] }) { ); } - const tooltip = `${runTooltip(run, now)} · open run detail`; + const tooltip = runTooltip(run, now); return ( - + + Run {tooltip} + ); })} diff --git a/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx deleted file mode 100644 index cb7086862..000000000 --- a/packages/ui/src/features/scouts/components/ScoutRunDetailView.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { - ArrowLeftIcon, - ArrowSquareOutIcon, - BrainIcon, - CompassIcon, - TerminalIcon, -} from "@phosphor-icons/react"; -import type { ScoutScratchpadEntry } from "@posthog/api-client/posthog-client"; -import { - deriveRunFailureKind, - formatRunDuration, - normalizeRunStatus, - prettifyScoutSkillName, - runDurationSeconds, - scoutSkillNameFromSlug, -} from "@posthog/core/scouts/scoutPresentation"; -import { getCloudUrlFromRegion } from "@posthog/shared"; -import { useAuthStateValue } from "@posthog/ui/features/auth/store"; -import { MarkdownRenderer } from "@posthog/ui/features/editor/components/MarkdownRenderer"; -import { DetailSection } from "@posthog/ui/features/inbox/components/DetailSection"; -import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; -import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { Badge, Box, Flex, Text } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; -import { useMemo, useState } from "react"; -import { useScoutRun } from "../hooks/useScoutRun"; -import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions"; -import { useScoutScratchpad } from "../hooks/useScoutScratchpad"; -import { ScoutEmissionCard } from "./ScoutEmissionCard"; - -export function ScoutRunDetailView({ - skillSlug, - runId, -}: { - skillSlug: string; - runId: string; -}) { - const skillName = scoutSkillNameFromSlug(skillSlug); - const displayName = prettifyScoutSkillName(skillName); - - const headerContent = useMemo( - () => ( - - - - {displayName} · run - - - ), - [displayName], - ); - useSetHeaderContent(headerContent); - - const { data: run, isLoading: runLoading } = useScoutRun(runId); - const emitted = (run?.emitted_count ?? 0) > 0; - const { data: emissions, isLoading: emissionsLoading } = useScoutRunEmissions( - runId, - { enabled: emitted }, - ); - const { data: scratchpad } = useScoutScratchpad(); - const cloudRegion = useAuthStateValue((state) => state.cloudRegion); - - const memoryEntries = useMemo( - () => - (scratchpad ?? []).filter((entry) => entry.created_by_run_id === runId), - [scratchpad, runId], - ); - - const now = new Date(); - const status = run ? normalizeRunStatus(run.status) : "unknown"; - const failureKind = run ? deriveRunFailureKind(run, now) : null; - const duration = run ? formatRunDuration(runDurationSeconds(run, now)) : ""; - const taskLogUrl = - run?.task_url && cloudRegion - ? `${getCloudUrlFromRegion(cloudRegion)}${run.task_url}` - : null; - - return ( - - - - - {displayName} - - - - Run - - {run ? ( - <> - - - {duration ? ( - {duration} - ) : null} - - ) : null} - - {run ? ( - - {run.skill_name} v{run.skill_version} · {run.run_id} - - ) : null} - - -
-
- {runLoading && !run ? ( - - ) : !run ? ( - - Run not found. It may be older than the recent window the API - returns. - - ) : ( - - - {run.summary ? ( - - - - ) : ( - - - No close-out summary - - - {failureKind === "timed_out" - ? "The run hit the 30-minute deadline before finishing. The task log is the only diagnostic for runs like this." - : status === "failed" - ? "The run failed before writing its close-out. Open the task log to see why." - : "The run has not written a summary yet."} - - - )} - - - - {!emitted ? ( - - Nothing emitted – on a healthy project this is the expected - close-out for most runs. - - ) : emissionsLoading ? ( - - ) : ( - - {(emissions ?? []).map((emission) => ( - - ))} - - )} - - - - {memoryEntries.length === 0 ? ( - - No scratchpad entries created by this run. Entries the run - read or updated in place aren't attributed yet, so this - only shows what it created. - - ) : ( - - {memoryEntries.map((entry) => ( - - ))} - - )} - - - - - - The full transcript lives on the backing task run in - PostHog. For failed runs it is the only way to see what - happened. - - {taskLogUrl ? ( - - Open task log - - - ) : ( - - No task link available - - )} - - - - )} -
-
-
- ); -} - -function RunStatusBadge({ - status, - emitted, - failureKind, -}: { - status: string; - emitted: boolean; - failureKind: "timed_out" | "error" | null; -}) { - if (status === "failed") { - return ( - - {failureKind === "timed_out" ? "Timed out" : "Failed"} - - ); - } - if (status === "running" || status === "queued") { - return ( - - {status === "running" ? "Running" : "Queued"} - - ); - } - return ( - - {emitted ? "Emitted" : "Quiet"} - - ); -} - -function MemoryEntryCard({ entry }: { entry: ScoutScratchpadEntry }) { - const [expanded, setExpanded] = useState(false); - return ( - - - {expanded ? ( - - {entry.content} - - ) : null} - - ); -} diff --git a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx index 53b5f3c19..58074e581 100644 --- a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx @@ -1,7 +1,7 @@ -import { ArrowRightIcon } from "@phosphor-icons/react"; +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Box, Flex, Text } from "@radix-ui/themes"; -import { Link } from "@tanstack/react-router"; import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions"; import { ScoutEmissionCard } from "./ScoutEmissionCard"; @@ -12,12 +12,10 @@ import { ScoutEmissionCard } from "./ScoutEmissionCard"; */ export function ScoutSignalsSection({ runs, - skillSlug, windowLabel, loading, }: { runs: ScoutRun[]; - skillSlug: string; windowLabel: string; loading: boolean; }) { @@ -35,7 +33,7 @@ export function ScoutSignalsSection({ ) : ( {emittedRuns.map((run) => ( - + ))} )} @@ -43,14 +41,9 @@ export function ScoutSignalsSection({ ); } -function RunEmissions({ - run, - skillSlug, -}: { - run: ScoutRun; - skillSlug: string; -}) { +function RunEmissions({ run }: { run: ScoutRun }) { const { data: emissions, isLoading } = useScoutRunEmissions(run.run_id); + const taskRunUrl = run.task_url ? getPostHogUrl(run.task_url) : null; if (isLoading) { return ( @@ -65,14 +58,17 @@ function RunEmissions({ key={emission.id} emission={emission} footerEnd={ - - View run - - + taskRunUrl ? ( + + Open task run + + + ) : undefined } /> ))} diff --git a/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts b/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts index 2f9fc66ca..88eea4c29 100644 --- a/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts +++ b/packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts @@ -2,10 +2,6 @@ export const scoutQueryKeys = { configs: (projectId: number | null) => ["scouts", "configs", projectId] as const, runs: (projectId: number | null) => ["scouts", "runs", projectId] as const, - run: (projectId: number | null, runId: string) => - ["scouts", "run", projectId, runId] as const, emissions: (projectId: number | null, runId: string) => ["scouts", "emissions", projectId, runId] as const, - scratchpad: (projectId: number | null) => - ["scouts", "scratchpad", projectId] as const, }; diff --git a/packages/ui/src/features/scouts/hooks/useScoutRun.ts b/packages/ui/src/features/scouts/hooks/useScoutRun.ts deleted file mode 100644 index f1046207e..000000000 --- a/packages/ui/src/features/scouts/hooks/useScoutRun.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { ScoutRun } from "@posthog/api-client/posthog-client"; -import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; -import { useQueryClient } from "@tanstack/react-query"; -import { useAuthStateValue } from "../../auth/store"; -import { scoutQueryKeys } from "./scoutQueryKeys"; - -export function useScoutRun(runId: string) { - const projectId = useAuthStateValue((state) => state.currentProjectId); - const queryClient = useQueryClient(); - return useAuthenticatedQuery( - scoutQueryKeys.run(projectId, runId), - (client) => - projectId ? client.getScoutRun(projectId, runId) : Promise.resolve(null), - { - enabled: !!projectId && !!runId, - staleTime: 15_000, - initialData: () => - queryClient - .getQueryData(scoutQueryKeys.runs(projectId)) - ?.find((run) => run.run_id === runId) ?? undefined, - }, - ); -} diff --git a/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts b/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts deleted file mode 100644 index ecbede4f3..000000000 --- a/packages/ui/src/features/scouts/hooks/useScoutScratchpad.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { ScoutScratchpadEntry } from "@posthog/api-client/posthog-client"; -import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; -import { useAuthStateValue } from "../../auth/store"; -import { scoutQueryKeys } from "./scoutQueryKeys"; - -/** - * Recent fleet scratchpad memory. The endpoint has no per-run filter, so - * run-detail views select entries by `created_by_run_id` client-side. Reads - * and upsert-updates are not attributed to runs server-side yet (scouts-ui - * api gap 6), so this only ever reveals entries a run CREATED. - */ -export function useScoutScratchpad() { - const projectId = useAuthStateValue((state) => state.currentProjectId); - return useAuthenticatedQuery( - scoutQueryKeys.scratchpad(projectId), - (client) => - projectId - ? client.searchScoutScratchpad(projectId, { limit: 100 }) - : Promise.resolve([]), - { enabled: !!projectId, staleTime: 60_000 }, - ); -} diff --git a/packages/ui/src/router/routeTree.gen.ts b/packages/ui/src/router/routeTree.gen.ts index 22965cea3..d6acf2a15 100644 --- a/packages/ui/src/router/routeTree.gen.ts +++ b/packages/ui/src/router/routeTree.gen.ts @@ -45,7 +45,6 @@ import { Route as CodeInboxReportsReportIdRouteImport } from './routes/code/inbo import { Route as CodeInboxPullsReportIdRouteImport } from './routes/code/inbox/pulls.$reportId' import { Route as CodeAgentsScoutsSkillNameRouteImport } from './routes/code/agents/scouts.$skillName' import { Route as CodeAgentsScoutsSkillNameIndexRouteImport } from './routes/code/agents/scouts.$skillName.index' -import { Route as CodeAgentsScoutsSkillNameRunsRunIdRouteImport } from './routes/code/agents/scouts.$skillName.runs.$runId' const WebsiteRoute = WebsiteRouteImport.update({ id: '/website', @@ -233,12 +232,6 @@ const CodeAgentsScoutsSkillNameIndexRoute = path: '/', getParentRoute: () => CodeAgentsScoutsSkillNameRoute, } as any) -const CodeAgentsScoutsSkillNameRunsRunIdRoute = - CodeAgentsScoutsSkillNameRunsRunIdRouteImport.update({ - id: '/runs/$runId', - path: '/runs/$runId', - getParentRoute: () => CodeAgentsScoutsSkillNameRoute, - } as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -277,7 +270,6 @@ export interface FileRoutesByFullPath { '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute - '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRoutesByTo { '/': typeof IndexRoute @@ -309,7 +301,6 @@ export interface FileRoutesByTo { '/code/inbox/reports': typeof CodeInboxReportsIndexRoute '/code/inbox/runs': typeof CodeInboxRunsIndexRoute '/code/agents/scouts/$skillName': typeof CodeAgentsScoutsSkillNameIndexRoute - '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRoutesById { __root__: typeof rootRouteImport @@ -349,7 +340,6 @@ export interface FileRoutesById { '/code/inbox/reports/': typeof CodeInboxReportsIndexRoute '/code/inbox/runs/': typeof CodeInboxRunsIndexRoute '/code/agents/scouts/$skillName/': typeof CodeAgentsScoutsSkillNameIndexRoute - '/code/agents/scouts/$skillName/runs/$runId': typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath @@ -390,7 +380,6 @@ export interface FileRouteTypes { | '/code/inbox/reports/' | '/code/inbox/runs/' | '/code/agents/scouts/$skillName/' - | '/code/agents/scouts/$skillName/runs/$runId' fileRoutesByTo: FileRoutesByTo to: | '/' @@ -422,7 +411,6 @@ export interface FileRouteTypes { | '/code/inbox/reports' | '/code/inbox/runs' | '/code/agents/scouts/$skillName' - | '/code/agents/scouts/$skillName/runs/$runId' id: | '__root__' | '/' @@ -461,7 +449,6 @@ export interface FileRouteTypes { | '/code/inbox/reports/' | '/code/inbox/runs/' | '/code/agents/scouts/$skillName/' - | '/code/agents/scouts/$skillName/runs/$runId' fileRoutesById: FileRoutesById } export interface RootRouteChildren { @@ -736,13 +723,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof CodeAgentsScoutsSkillNameIndexRouteImport parentRoute: typeof CodeAgentsScoutsSkillNameRoute } - '/code/agents/scouts/$skillName/runs/$runId': { - id: '/code/agents/scouts/$skillName/runs/$runId' - path: '/runs/$runId' - fullPath: '/code/agents/scouts/$skillName/runs/$runId' - preLoaderRoute: typeof CodeAgentsScoutsSkillNameRunsRunIdRouteImport - parentRoute: typeof CodeAgentsScoutsSkillNameRoute - } } } @@ -770,14 +750,11 @@ const WebsiteRouteWithChildren = interface CodeAgentsScoutsSkillNameRouteChildren { CodeAgentsScoutsSkillNameIndexRoute: typeof CodeAgentsScoutsSkillNameIndexRoute - CodeAgentsScoutsSkillNameRunsRunIdRoute: typeof CodeAgentsScoutsSkillNameRunsRunIdRoute } const CodeAgentsScoutsSkillNameRouteChildren: CodeAgentsScoutsSkillNameRouteChildren = { CodeAgentsScoutsSkillNameIndexRoute: CodeAgentsScoutsSkillNameIndexRoute, - CodeAgentsScoutsSkillNameRunsRunIdRoute: - CodeAgentsScoutsSkillNameRunsRunIdRoute, } const CodeAgentsScoutsSkillNameRouteWithChildren = diff --git a/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx b/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx deleted file mode 100644 index 805956bfd..000000000 --- a/packages/ui/src/router/routes/code/agents/scouts.$skillName.runs.$runId.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { ScoutRunDetailView } from "@posthog/ui/features/scouts/components/ScoutRunDetailView"; -import { createFileRoute } from "@tanstack/react-router"; - -export const Route = createFileRoute( - "/code/agents/scouts/$skillName/runs/$runId", -)({ - component: ScoutRunDetailRoute, -}); - -function ScoutRunDetailRoute() { - const { skillName, runId } = Route.useParams(); - return ; -} From d702466169679da00252302280e6833a8700bbf5 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 20:32:50 +0100 Subject: [PATCH 17/37] feat(scouts): cloud skill link always visible on scout rows --- packages/ui/src/features/scouts/components/ScoutRowCard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index b6d90a671..1dbefa29a 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -80,7 +80,7 @@ export function ScoutRowCard({ target="_blank" rel="noreferrer" aria-label={`${config.skill_name} skill in PostHog`} - className="text-gray-9 opacity-0 transition-opacity hover:text-accent-11 focus-visible:opacity-100 group-hover:opacity-100" + className="text-gray-9 transition-colors hover:text-accent-11" > From 26c363c186441a8dc8aa5eb751021bf8d3f1ae2e Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 20:35:32 +0100 Subject: [PATCH 18/37] feat(scouts): whole fleet row navigates to scout detail --- .../features/scouts/components/ScoutRowCard.tsx | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index 1dbefa29a..7e6ce59d6 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -54,7 +54,7 @@ export function ScoutRowCard({ return ( @@ -64,7 +64,9 @@ export function ScoutRowCard({ {title} @@ -80,7 +82,7 @@ export function ScoutRowCard({ target="_blank" rel="noreferrer" aria-label={`${config.skill_name} skill in PostHog`} - className="text-gray-9 transition-colors hover:text-accent-11" + className="relative text-gray-9 transition-colors hover:text-accent-11" > @@ -98,8 +100,10 @@ export function ScoutRowCard({ ) : null} - - + + + +
- - Run counts and emitted totals cover the last {SCOUT_RUNS_WINDOW_HOURS}{" "} - hours of fleet runs. New scouts are created as{" "} - signals-scout-* skills in - your PostHog project. - + + + Run counts and emitted totals cover the last {SCOUT_RUNS_WINDOW_HOURS}{" "} + hours of fleet runs. New scouts are created as{" "} + signals-scout-* skills + in your PostHog project. + + +
); } @@ -175,6 +179,7 @@ function ScoutsEmptyState() { signals-scout-* skills in PostHog. + ); } From 67a562e49de5addf5898c3c68bc00893e3de67b1 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:13:28 +0100 Subject: [PATCH 20/37] feat(scouts): drop per-row status dot, run boxes carry the signal --- .../scouts/components/ScoutBadges.tsx | 57 +------------------ .../scouts/components/ScoutRowCard.tsx | 20 ++----- 2 files changed, 7 insertions(+), 70 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutBadges.tsx b/packages/ui/src/features/scouts/components/ScoutBadges.tsx index c13d4ad76..92068eacb 100644 --- a/packages/ui/src/features/scouts/components/ScoutBadges.tsx +++ b/packages/ui/src/features/scouts/components/ScoutBadges.tsx @@ -1,59 +1,6 @@ import type { ScoutConfig } from "@posthog/api-client/posthog-client"; -import { - getScoutOrigin, - isRunStuck, - normalizeRunStatus, - type ScoutRollup, -} from "@posthog/core/scouts/scoutPresentation"; -import { Badge, Tooltip } from "@radix-ui/themes"; - -export type ScoutRowState = "ok" | "running" | "failing" | "stuck" | "disabled"; - -export function deriveScoutRowState( - config: ScoutConfig, - rollup: ScoutRollup | undefined, - now: Date, -): ScoutRowState { - if (!config.enabled) return "disabled"; - if (rollup?.runningRun) { - return isRunStuck(rollup.runningRun, now) ? "stuck" : "running"; - } - if ( - rollup?.latestRun && - normalizeRunStatus(rollup.latestRun.status) === "failed" - ) { - return "failing"; - } - return "ok"; -} - -const ROW_STATE_DOT_CLASS: Record = { - ok: "bg-(--green-9)", - running: "bg-(--blue-9) animate-pulse", - failing: "bg-(--amber-9)", - stuck: "bg-(--red-9)", - disabled: "bg-(--gray-7)", -}; - -const ROW_STATE_LABELS: Record = { - ok: "Healthy – last run completed", - running: "Running now", - failing: "Last run failed", - stuck: "Running past the deadline – may be stuck", - disabled: "Disabled", -}; - -export function ScoutStatusDot({ state }: { state: ScoutRowState }) { - return ( - - - - ); -} +import { getScoutOrigin } from "@posthog/core/scouts/scoutPresentation"; +import { Badge } from "@radix-ui/themes"; export function ScoutOriginBadge({ skillName }: { skillName: string }) { const origin = getScoutOrigin(skillName); diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index 7e6ce59d6..de311e952 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -11,17 +11,12 @@ import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useState } from "react"; import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; -import { - DryRunBadge, - deriveScoutRowState, - ScoutOriginBadge, - ScoutStatusDot, -} from "./ScoutBadges"; +import { DryRunBadge, ScoutOriginBadge } from "./ScoutBadges"; import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; import { ScoutRunBoxes } from "./ScoutRunBoxes"; /** - * The one scout card: dot, name, badges, cadence, emitted count, run boxes, + * The one scout card: name, badges, cadence, emitted count, run boxes, * enable switch, and a gear that expands the settings form. Used both as the * fleet list row and as the header card on the scout detail screen, so the * two surfaces always look and behave the same. @@ -37,18 +32,13 @@ export function ScoutRowCard({ onUpdate: (configId: string, updates: ScoutConfigUpdate) => void; linkToDetail?: boolean; }) { - const now = new Date(); - const state = deriveScoutRowState(config, rollup, now); const [settingsOpen, setSettingsOpen] = useState(false); const cloudSkillUrl = skillUrl(config.skill_name); const title = ( - <> - - - {prettifyScoutSkillName(config.skill_name)} - - + + {prettifyScoutSkillName(config.skill_name)} + ); return ( From b3defb4125d774299de8ff1d20bb3a067afe9c6d Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:14:11 +0100 Subject: [PATCH 21/37] feat(scouts): explain canonical/custom and dry-run badges with tooltips --- .../scouts/components/ScoutBadges.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutBadges.tsx b/packages/ui/src/features/scouts/components/ScoutBadges.tsx index 92068eacb..e9dbcc1cd 100644 --- a/packages/ui/src/features/scouts/components/ScoutBadges.tsx +++ b/packages/ui/src/features/scouts/components/ScoutBadges.tsx @@ -1,27 +1,37 @@ import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { getScoutOrigin } from "@posthog/core/scouts/scoutPresentation"; -import { Badge } from "@radix-ui/themes"; +import { Badge, Tooltip } from "@radix-ui/themes"; export function ScoutOriginBadge({ skillName }: { skillName: string }) { const origin = getScoutOrigin(skillName); return ( - - {origin === "canonical" ? "Canonical" : "Custom"} - + + {origin === "canonical" ? "Canonical" : "Custom"} + + ); } export function DryRunBadge({ config }: { config: ScoutConfig }) { if (config.emit) return null; return ( - - Dry run - + + + Dry run + + ); } From e9cfb6dd13f9bf1d2032deffbb077df40351636c Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:16:22 +0100 Subject: [PATCH 22/37] fix(scouts): lift origin/dry-run badges above the row stretched-link so tooltips fire --- .../ui/src/features/scouts/components/ScoutBadges.tsx | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutBadges.tsx b/packages/ui/src/features/scouts/components/ScoutBadges.tsx index e9dbcc1cd..19b3a1b89 100644 --- a/packages/ui/src/features/scouts/components/ScoutBadges.tsx +++ b/packages/ui/src/features/scouts/components/ScoutBadges.tsx @@ -16,7 +16,7 @@ export function ScoutOriginBadge({ skillName }: { skillName: string }) { variant="soft" color={origin === "canonical" ? "gray" : "iris"} size="1" - className="text-[11px]" + className="relative text-[11px]" > {origin === "canonical" ? "Canonical" : "Custom"} @@ -28,7 +28,12 @@ export function DryRunBadge({ config }: { config: ScoutConfig }) { if (config.emit) return null; return ( - + Dry run From 8b8adb8ebea2d24403a546b8560e68777686f5a3 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:18:24 +0100 Subject: [PATCH 23/37] feat(scouts): add learn-more link to the scouts subsection description --- .../inbox/components/ConfigureAgentsSection.tsx | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 42e98f46e..1034233b8 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -108,7 +108,21 @@ export function ConfigureAgentsSection() { + Scheduled agents that sweep this project on a cadence and emit + findings to your inbox.{" "} + {/* Placeholder docs link until a dedicated scouts page exists. */} + + Learn more + + + } > From 07a4ccb52eab997a5b2e4175fa4c5a58d0253870 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:22:10 +0100 Subject: [PATCH 24/37] feat(scouts): run boxes recede quiet runs to gray so emitted and failures pop --- .../ui/src/features/scouts/components/ScoutRunBoxes.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx index 4d9673981..09c969196 100644 --- a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx @@ -10,14 +10,16 @@ import { formatRelativeTimeLong } from "@posthog/shared"; import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; +// Quiet is the common, healthy baseline so it recedes to gray; saturated +// color only means something happened — iris payoff, red/amber trouble. const OUTCOME_BOX_CLASS: Record = { emitted: "bg-(--iris-9)", - quiet: "bg-(--green-7)", + quiet: "bg-(--gray-5)", error: "bg-(--red-9)", timed_out: "bg-(--amber-9)", running: "bg-(--blue-9) animate-pulse", stuck: "bg-(--red-9) animate-pulse", - queued: "bg-(--gray-6)", + queued: "border border-(--gray-7) bg-transparent", unknown: "bg-(--gray-6)", }; From f66a2a53d140fc7d0682ca876e7a52bf5347f855 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:50:01 +0100 Subject: [PATCH 25/37] feat(scouts): fleet-overview chat CTA prefilling a task with the exploring skill --- .../scouts/components/ScoutsFleetSection.tsx | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index a9d0e7702..c42338609 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -1,4 +1,4 @@ -import { CaretDownIcon, CompassIcon } from "@phosphor-icons/react"; +import { CaretDownIcon, CompassIcon, SparkleIcon } from "@phosphor-icons/react"; import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { computeFleetSummary, @@ -10,6 +10,7 @@ import { scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; @@ -18,6 +19,21 @@ import { useScoutRuns } from "../hooks/useScoutRuns"; import { ScoutHelperSkillLinks } from "./ScoutHelperSkillLinks"; import { ScoutRowCard } from "./ScoutRowCard"; +// Templated prompt for the fleet-overview chat CTA. The new-task composer is +// prefilled with this (like the inbox discuss flow's question) so the user can +// tweak before launching. +const FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? + +Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: + +- The fleet: which scouts exist, enabled vs disabled, and their cadences +- Recent run health: success rate, failures and timeouts, anything stuck +- Output: which scouts emitted signals recently, emit rate, signal-to-noise +- Memory: notable scratchpad entries the fleet has learned +- Recommendations: anything misconfigured, noisy, or worth tuning + +Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; + /** * Expandable scout fleet manager for the agents config page. Collapsed it is * a one-line pulse; expanded it lists every scout with inline config controls. @@ -145,6 +161,8 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
+ + Run counts and emitted totals cover the last {SCOUT_RUNS_WINDOW_HOURS}{" "} @@ -158,6 +176,23 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { ); } +/** + * Suggestion-chip CTA that opens the new-task composer prefilled with a + * fleet-overview question driving the exploring-signals-scouts skill. + */ +function FleetOverviewChatCta() { + return ( + + ); +} + function ScoutsEmptyState() { return ( Date: Wed, 10 Jun 2026 21:51:30 +0100 Subject: [PATCH 26/37] feat(scouts): move fleet-overview chat CTA above the scout list --- .../ui/src/features/scouts/components/ScoutsFleetSection.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index c42338609..9255fc4ad 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -147,6 +147,8 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { + + {/* Bounded to roughly 10 rows; larger fleets scroll within the section. */}
@@ -161,8 +163,6 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
- - Run counts and emitted totals cover the last {SCOUT_RUNS_WINDOW_HOURS}{" "} From c2fc6facb30808f7ad29541d631b2a437ddd2fcb Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:55:41 +0100 Subject: [PATCH 27/37] feat(scouts): fleet-overview CTA fires an auto-mode cloud task like discuss --- .../features/inbox/hooks/useCreatePrReport.ts | 6 +- .../features/inbox/hooks/useDiscussReport.ts | 8 +- .../inbox/hooks/useInboxCloudTaskRunner.ts | 26 +++--- .../scouts/components/ScoutsFleetSection.tsx | 32 +++---- .../scouts/hooks/useFleetOverviewTask.ts | 83 +++++++++++++++++++ 5 files changed, 117 insertions(+), 38 deletions(-) create mode 100644 packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts diff --git a/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts b/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts index 49816f859..8a5980d56 100644 --- a/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts +++ b/packages/ui/src/features/inbox/hooks/useCreatePrReport.ts @@ -39,7 +39,7 @@ export function useCreatePrReport({ const buildInput = useCallback( (ctx: InboxCloudTaskInputContext): TaskCreationInput => { const prompt = buildCreatePrReportPrompt({ - reportId: ctx.reportId, + reportId, isDevBuild: import.meta.env.DEV, }); const targetRepo = ctx.cloudRepository.toLowerCase(); @@ -61,10 +61,10 @@ export function useCreatePrReport({ reasoningLevel: ctx.reasoningLevel, cloudPrAuthorshipMode: "user", cloudRunSource: "signal_report", - signalReportId: ctx.reportId, + signalReportId: reportId, }; }, - [baseBranchOverrides], + [baseBranchOverrides, reportId], ); const analyticsExtras = useMemo( diff --git a/packages/ui/src/features/inbox/hooks/useDiscussReport.ts b/packages/ui/src/features/inbox/hooks/useDiscussReport.ts index 2fda4991e..79e05999b 100644 --- a/packages/ui/src/features/inbox/hooks/useDiscussReport.ts +++ b/packages/ui/src/features/inbox/hooks/useDiscussReport.ts @@ -31,8 +31,8 @@ export function useDiscussReport({ const buildInput = useCallback( (ctx: InboxCloudTaskInputContext): TaskCreationInput => { const prompt = buildDiscussReportPrompt({ - reportId: ctx.reportId, - reportTitle: ctx.reportTitle, + reportId, + reportTitle, question: pendingQuestionRef.current, isDevBuild: import.meta.env.DEV, }); @@ -48,10 +48,10 @@ export function useDiscussReport({ reasoningLevel: ctx.reasoningLevel, cloudPrAuthorshipMode: "user", cloudRunSource: "signal_report", - signalReportId: ctx.reportId, + signalReportId: reportId, }; }, - [], + [reportId, reportTitle], ); const { run, isRunning } = useInboxCloudTaskRunner({ diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts index a82b8317f..8a7fff282 100644 --- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts +++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts @@ -41,8 +41,8 @@ export interface InboxCloudTaskCopy { /** Context the variant uses to assemble the TaskCreationInput. */ export interface InboxCloudTaskInputContext { - reportId: string; - reportTitle: string | null; + reportId?: string; + reportTitle?: string | null; cloudRepository: string; githubUserIntegrationId: string; adapter: "claude" | "codex"; @@ -51,8 +51,9 @@ export interface InboxCloudTaskInputContext { } export interface UseInboxCloudTaskRunnerOptions { - reportId: string; - reportTitle: string | null; + /** Backing signal report, when the task is report-scoped (Create PR, Discuss). */ + reportId?: string; + reportTitle?: string | null; cloudRepository: string | null; copy: InboxCloudTaskCopy; /** Logger scope used for failure traces. */ @@ -71,9 +72,10 @@ export interface UseInboxCloudTaskRunnerReturn { } /** - * Shared driver for the inbox-side "create a cloud task from a report" flows - * (Create PR, Discuss). Variants supply copy, telemetry, and a `buildInput` - * callback that assembles the per-variant prompt / branch / metadata. + * Shared driver for one-click "create an auto-mode cloud task" flows + * (Create PR, Discuss, scout fleet overview). Variants supply copy, telemetry, + * and a `buildInput` callback that assembles the per-variant prompt / branch / + * metadata; report context is optional for flows not scoped to a report. */ export function useInboxCloudTaskRunner({ reportId, @@ -154,9 +156,13 @@ export function useInboxCloudTaskRunner({ created_from: "command-menu", repository_provider: "github", workspace_mode: "cloud", - cloud_run_source: "signal_report", - cloud_pr_authorship_mode: "user", - signal_report_id: reportId, + ...(reportId + ? { + cloud_run_source: "signal_report", + cloud_pr_authorship_mode: "user", + signal_report_id: reportId, + } + : { cloud_run_source: "manual" }), adapter, ...analyticsExtras, }); diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 9255fc4ad..e92bfe186 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -10,30 +10,15 @@ import { scoutRunsWindowLabel, } from "@posthog/core/scouts/scoutRunsWindow"; import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; -import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; +import { useFleetOverviewTask } from "../hooks/useFleetOverviewTask"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; import { ScoutHelperSkillLinks } from "./ScoutHelperSkillLinks"; import { ScoutRowCard } from "./ScoutRowCard"; -// Templated prompt for the fleet-overview chat CTA. The new-task composer is -// prefilled with this (like the inbox discuss flow's question) so the user can -// tweak before launching. -const FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? - -Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: - -- The fleet: which scouts exist, enabled vs disabled, and their cadences -- Recent run health: success rate, failures and timeouts, anything stuck -- Output: which scouts emitted signals recently, emit rate, signal-to-noise -- Memory: notable scratchpad entries the fleet has learned -- Recommendations: anything misconfigured, noisy, or worth tuning - -Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; - /** * Expandable scout fleet manager for the agents config page. Collapsed it is * a one-line pulse; expanded it lists every scout with inline config controls. @@ -177,18 +162,23 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { } /** - * Suggestion-chip CTA that opens the new-task composer prefilled with a - * fleet-overview question driving the exploring-signals-scouts skill. + * Suggestion-chip CTA that fires an auto-mode cloud task asking the + * exploring-signals-scouts skill for a fleet overview, then navigates to it — + * same one-click shape as the inbox discuss / create-PR flows. */ function FleetOverviewChatCta() { + const { runFleetOverview, isRunning } = useFleetOverviewTask(); return ( ); } diff --git a/packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts b/packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts new file mode 100644 index 000000000..02fb9eaa5 --- /dev/null +++ b/packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts @@ -0,0 +1,83 @@ +import type { TaskCreationInput } from "@posthog/core/task-detail/taskService"; +import { + type InboxCloudTaskInputContext, + useInboxCloudTaskRunner, +} from "@posthog/ui/features/inbox/hooks/useInboxCloudTaskRunner"; +import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; +import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { useCallback, useMemo } from "react"; + +// Templated prompt behind the "How is my scout fleet performing?" CTA. The +// agent leans on the exploring-signals-scouts skill from the PostHog MCP. +const FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? + +Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: + +- The fleet: which scouts exist, enabled vs disabled, and their cadences +- Recent run health: success rate, failures and timeouts, anything stuck +- Output: which scouts emitted signals recently, emit rate, signal-to-noise +- Memory: notable scratchpad entries the fleet has learned +- Recommendations: anything misconfigured, noisy, or worth tuning + +Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; + +interface UseFleetOverviewTaskReturn { + /** Create the auto-mode fleet-overview task and navigate to it on success. */ + runFleetOverview: () => Promise; + /** True while the task is being created. */ + isRunning: boolean; +} + +/** + * One-click fleet-overview task, mirroring the inbox discuss flow: create an + * auto-mode cloud task and jump straight to it. The repository falls back to + * the last-used cloud repository, then the first connected one. + */ +export function useFleetOverviewTask(): UseFleetOverviewTaskReturn { + const { repositories } = useUserRepositoryIntegration(); + const lastUsedCloudRepository = useSettingsStore( + (state) => state.lastUsedCloudRepository, + ); + + const cloudRepository = useMemo(() => { + const normalizedLastUsed = lastUsedCloudRepository?.toLowerCase() ?? null; + if (normalizedLastUsed && repositories.includes(normalizedLastUsed)) { + return normalizedLastUsed; + } + return repositories[0] ?? null; + }, [lastUsedCloudRepository, repositories]); + + const buildInput = useCallback( + (ctx: InboxCloudTaskInputContext): TaskCreationInput => ({ + content: FLEET_OVERVIEW_PROMPT, + taskDescription: FLEET_OVERVIEW_PROMPT, + repository: ctx.cloudRepository, + githubUserIntegrationId: ctx.githubUserIntegrationId, + workspaceMode: "cloud", + executionMode: "auto", + adapter: ctx.adapter, + model: ctx.model, + reasoningLevel: ctx.reasoningLevel, + }), + [], + ); + + const { run, isRunning } = useInboxCloudTaskRunner({ + cloudRepository, + loggerScope: "scout-fleet-overview", + copy: { + loadingTitle: "Starting fleet overview...", + errorTitle: "Failed to start fleet overview", + missingRepository: + "Connect a GitHub repository before starting a fleet overview", + missingIntegration: + "Connect a GitHub integration to start a fleet overview", + signedOut: "Sign in to start a fleet overview", + missingModel: + "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", + }, + buildInput, + }); + + return { runFleetOverview: run, isRunning }; +} From 77c5e952aedb0e275d1d97388b84a268df42aa67 Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 21:59:52 +0100 Subject: [PATCH 28/37] feat(scouts): second chat CTA for recent emitted signals, generic chip + hook --- .../scouts/components/ScoutsFleetSection.tsx | 47 ++++++++--- ...eetOverviewTask.ts => useScoutChatTask.ts} | 79 +++++++++++++------ 2 files changed, 91 insertions(+), 35 deletions(-) rename packages/ui/src/features/scouts/hooks/{useFleetOverviewTask.ts => useScoutChatTask.ts} (51%) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index e92bfe186..4bb3845f6 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -12,7 +12,11 @@ import { import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useMemo, useState } from "react"; -import { useFleetOverviewTask } from "../hooks/useFleetOverviewTask"; +import { + SCOUT_FLEET_OVERVIEW_PROMPT, + SCOUT_RECENT_SIGNALS_PROMPT, + useScoutChatTask, +} from "../hooks/useScoutChatTask"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; @@ -132,7 +136,20 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { - + + + + {/* Bounded to roughly 10 rows; larger fleets scroll within the section. */}
@@ -163,22 +180,34 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { /** * Suggestion-chip CTA that fires an auto-mode cloud task asking the - * exploring-signals-scouts skill for a fleet overview, then navigates to it — + * exploring-signals-scouts skill a templated question, then navigates to it — * same one-click shape as the inbox discuss / create-PR flows. */ -function FleetOverviewChatCta() { - const { runFleetOverview, isRunning } = useFleetOverviewTask(); +function ScoutChatCta({ + label, + prompt, + taskLabel, + loggerScope, +}: { + label: string; + prompt: string; + taskLabel: string; + loggerScope: string; +}) { + const { runTask, isRunning } = useScoutChatTask({ + prompt, + taskLabel, + loggerScope, + }); return ( ); } diff --git a/packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts similarity index 51% rename from packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts rename to packages/ui/src/features/scouts/hooks/useScoutChatTask.ts index 02fb9eaa5..a7ac6bb34 100644 --- a/packages/ui/src/features/scouts/hooks/useFleetOverviewTask.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts @@ -7,9 +7,9 @@ import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/ import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCallback, useMemo } from "react"; -// Templated prompt behind the "How is my scout fleet performing?" CTA. The -// agent leans on the exploring-signals-scouts skill from the PostHog MCP. -const FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? +// Templated prompts behind the scout chat CTA chips. The agent leans on the +// exploring-signals-scouts skill from the PostHog MCP. +export const SCOUT_FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: @@ -21,19 +21,43 @@ Use the exploring-signals-scouts skill from the PostHog MCP to survey the signal Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; -interface UseFleetOverviewTaskReturn { - /** Create the auto-mode fleet-overview task and navigate to it on success. */ - runFleetOverview: () => Promise; +export const SCOUT_RECENT_SIGNALS_PROMPT = `What signals have my scouts emitted recently? + +Use the exploring-signals-scouts skill from the PostHog MCP to pull the most recent scout runs that emitted findings and walk me through the signals: + +- What each signal says, in plain language +- Which scout emitted it, when, and its severity/confidence where available +- Whether it looks genuinely actionable or like noise + +Group by scout, newest first. Close with a short note on overall signal quality and any scouts that look noisy or suspiciously silent. If the skill is unavailable, fall back to the signals-scout MCP tools directly (runs list with emitted filter, run emissions).`; + +interface UseScoutChatTaskOptions { + /** The templated question the task is created with. */ + prompt: string; + /** Short noun used in toast copy, e.g. "fleet overview". */ + taskLabel: string; + /** Logger scope used for failure traces. */ + loggerScope: string; +} + +interface UseScoutChatTaskReturn { + /** Create the auto-mode scout chat task and navigate to it on success. */ + runTask: () => Promise; /** True while the task is being created. */ isRunning: boolean; } /** - * One-click fleet-overview task, mirroring the inbox discuss flow: create an - * auto-mode cloud task and jump straight to it. The repository falls back to - * the last-used cloud repository, then the first connected one. + * One-click scout chat task, mirroring the inbox discuss flow: create an + * auto-mode cloud task from a templated question and jump straight to it. The + * repository falls back to the last-used cloud repository, then the first + * connected one. */ -export function useFleetOverviewTask(): UseFleetOverviewTaskReturn { +export function useScoutChatTask({ + prompt, + taskLabel, + loggerScope, +}: UseScoutChatTaskOptions): UseScoutChatTaskReturn { const { repositories } = useUserRepositoryIntegration(); const lastUsedCloudRepository = useSettingsStore( (state) => state.lastUsedCloudRepository, @@ -49,8 +73,8 @@ export function useFleetOverviewTask(): UseFleetOverviewTaskReturn { const buildInput = useCallback( (ctx: InboxCloudTaskInputContext): TaskCreationInput => ({ - content: FLEET_OVERVIEW_PROMPT, - taskDescription: FLEET_OVERVIEW_PROMPT, + content: prompt, + taskDescription: prompt, repository: ctx.cloudRepository, githubUserIntegrationId: ctx.githubUserIntegrationId, workspaceMode: "cloud", @@ -59,25 +83,28 @@ export function useFleetOverviewTask(): UseFleetOverviewTaskReturn { model: ctx.model, reasoningLevel: ctx.reasoningLevel, }), - [], + [prompt], ); - const { run, isRunning } = useInboxCloudTaskRunner({ - cloudRepository, - loggerScope: "scout-fleet-overview", - copy: { - loadingTitle: "Starting fleet overview...", - errorTitle: "Failed to start fleet overview", - missingRepository: - "Connect a GitHub repository before starting a fleet overview", - missingIntegration: - "Connect a GitHub integration to start a fleet overview", - signedOut: "Sign in to start a fleet overview", + const copy = useMemo( + () => ({ + loadingTitle: `Starting ${taskLabel}...`, + errorTitle: `Failed to start ${taskLabel}`, + missingRepository: `Connect a GitHub repository before starting a ${taskLabel}`, + missingIntegration: `Connect a GitHub integration to start a ${taskLabel}`, + signedOut: `Sign in to start a ${taskLabel}`, missingModel: "Couldn't resolve a default model. Open the task page once and pick a model, then try again.", - }, + }), + [taskLabel], + ); + + const { run, isRunning } = useInboxCloudTaskRunner({ + cloudRepository, + loggerScope, + copy, buildInput, }); - return { runFleetOverview: run, isRunning }; + return { runTask: run, isRunning }; } From 234f2d769ca362a19d96199a60c0e0f8534259da Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 22:04:01 +0100 Subject: [PATCH 29/37] feat(scouts): per-scout chat CTA sparkle on row cards --- .../scouts/components/ScoutRowCard.tsx | 46 ++++++++++++++++++- .../features/scouts/hooks/useScoutChatTask.ts | 18 ++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index de311e952..208ad5189 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -1,4 +1,8 @@ -import { ArrowSquareOutIcon, GearSixIcon } from "@phosphor-icons/react"; +import { + ArrowSquareOutIcon, + GearSixIcon, + SparkleIcon, +} from "@phosphor-icons/react"; import type { ScoutConfig } from "@posthog/api-client/posthog-client"; import { formatRunIntervalShort, @@ -9,7 +13,11 @@ import { import { skillUrl } from "@posthog/ui/utils/posthogLinks"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; -import { useState } from "react"; +import { useMemo, useState } from "react"; +import { + buildScoutCheckinPrompt, + useScoutChatTask, +} from "../hooks/useScoutChatTask"; import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; import { DryRunBadge, ScoutOriginBadge } from "./ScoutBadges"; import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; @@ -95,6 +103,7 @@ export function ScoutRowCard({ + + + ); +} diff --git a/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts index a7ac6bb34..9e1771600 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts @@ -31,6 +31,24 @@ Use the exploring-signals-scouts skill from the PostHog MCP to pull the most rec Group by scout, newest first. Close with a short note on overall signal quality and any scouts that look noisy or suspiciously silent. If the skill is unavailable, fall back to the signals-scout MCP tools directly (runs list with emitted filter, run emissions).`; +/** Per-scout variant of the templated questions, scoped to one skill. */ +export function buildScoutCheckinPrompt( + skillName: string, + displayName: string, +): string { + return `How is my ${displayName} scout performing? + +Use the exploring-signals-scouts skill from the PostHog MCP to dig into the \`${skillName}\` scout on this project: + +- Its config: enabled, cadence, dry-run posture +- Recent run history: successes, failures, timeouts, durations +- Signals it emitted recently and whether they look genuinely actionable +- Scratchpad memory the fleet holds that relates to this scout +- Whether its scope, thresholds, and schedule look right — suggest tuning if not + +Lead with a short verdict. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, run emissions, scratchpad search).`; +} + interface UseScoutChatTaskOptions { /** The templated question the task is created with. */ prompt: string; From 51cbe5d60b0856e88493379e9a3a1abd2a7f67ac Mon Sep 17 00:00:00 2001 From: Andrew Maguire Date: Wed, 10 Jun 2026 22:05:46 +0100 Subject: [PATCH 30/37] feat(scouts): per-scout chat tooltip says chat with PostHog --- packages/ui/src/features/scouts/components/ScoutRowCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index 208ad5189..805bf753d 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -145,12 +145,12 @@ function ScoutChatButton({ skillName }: { skillName: string }) { loggerScope: "scout-checkin", }); return ( - + + ) : null} )} @@ -44,7 +81,11 @@ export function ScoutSignalsSection({ } function RunEmissions({ run }: { run: ScoutRun }) { - const { data: emissions, isLoading } = useScoutRunEmissions(run.run_id); + const { + data: emissions, + isLoading, + isError, + } = useScoutRunEmissions(run.run_id); const taskRunUrl = run.task_url ? getPostHogUrl(run.task_url) : null; if (isLoading) { @@ -53,32 +94,35 @@ function RunEmissions({ run }: { run: ScoutRun }) { ); } + // The run-level emitted_count promised signals; an errored or empty + // emissions response must say so rather than render nothing. + if (isError || !emissions || emissions.length === 0) { + return ( + + + {isError + ? "Couldn't load this run's signals." + : "No signal details available for this run."} + + {taskRunUrl ? : null} + + ); + } + return ( - {(emissions ?? []).map((emission) => ( + {emissions.map((emission) => ( - track(ANALYTICS_EVENTS.SCOUT_ACTION, { - action_type: "open_task_run", - surface: "scout_detail", - skill_name: run.skill_name, - run_id: run.run_id, - }) - } - className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline hover:text-accent-12" - > - Open task run - - + ) : undefined } /> @@ -86,3 +130,31 @@ function RunEmissions({ run }: { run: ScoutRun }) { ); } + +function TaskRunLink({ + run, + taskRunUrl, +}: { + run: ScoutRun; + taskRunUrl: string; +}) { + return ( + + track(ANALYTICS_EVENTS.SCOUT_ACTION, { + action_type: "open_task_run", + surface: "scout_detail", + skill_name: run.skill_name, + run_id: run.run_id, + }) + } + className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline hover:text-accent-12" + > + Open task run + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index ed409062a..e5ab518d3 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -35,7 +35,7 @@ const EMPTY_CONFIGS: ScoutConfig[] = []; * Per-scout drill-down (run history, run detail) stays on its own routes. */ export function ScoutsFleetSection() { - const { data: configs, isLoading } = useScoutConfigs(); + const { data: configs, isLoading, isError, refetch } = useScoutConfigs(); const [expanded, setExpanded] = useState(false); const lastRunAt = useMemo(() => { @@ -54,6 +54,31 @@ export function ScoutsFleetSection() { ); } + // A failed request must not masquerade as an empty fleet — a missing scope + // or regional rollout gap would otherwise be indistinguishable from + // "no scouts yet". + if (isError) { + return ( + + + Couldn't load the scout fleet. The scout API may be unavailable + or this token may lack the signal_scout scope. + + + + ); + } + if (!configs || configs.length === 0) { return ; } diff --git a/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts index 9d58eb80c..b8c71887e 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutConfigMutations.ts @@ -4,7 +4,7 @@ import { ANALYTICS_EVENTS } from "@posthog/shared"; import { useAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { track } from "@posthog/ui/shell/analytics"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback } from "react"; +import { useCallback, useRef } from "react"; import { toast } from "sonner"; import { useAuthStateValue } from "../../auth/store"; import { scoutQueryKeys } from "./scoutQueryKeys"; @@ -45,18 +45,21 @@ export function useScoutConfigMutations() { const client = useAuthenticatedClient(); const queryClient = useQueryClient(); const projectId = useAuthStateValue((state) => state.currentProjectId); + const inFlightCount = useRef(0); const updateConfig = useCallback( async (configId: string, updates: ScoutConfigUpdate) => { if (!client || !projectId) return; const queryKey = scoutQueryKeys.configs(projectId); - const previous = queryClient.getQueryData(queryKey); - const previousConfig = previous?.find((config) => config.id === configId); + const previousConfig = queryClient + .getQueryData(queryKey) + ?.find((config) => config.id === configId); queryClient.setQueryData(queryKey, (configs) => configs?.map((config) => config.id === configId ? { ...config, ...updates } : config, ), ); + inFlightCount.current++; try { const updated = await client.updateScoutConfig( projectId, @@ -68,13 +71,28 @@ export function useScoutConfigMutations() { ); trackConfigChange(previousConfig, updates, true); } catch (error: unknown) { - queryClient.setQueryData(queryKey, previous); + // Roll back only this config so concurrent edits to other scouts + // survive; same-scout overlap reconciles via the settle invalidation. + if (previousConfig) { + queryClient.setQueryData(queryKey, (configs) => + configs?.map((config) => + config.id === configId ? previousConfig : config, + ), + ); + } trackConfigChange(previousConfig, updates, false); const message = error instanceof Error ? error.message : "Failed to update scout config"; toast.error(message); + } finally { + // Concurrent PATCHes to one scout can settle out of order; once the + // last one lands, reconcile the cache against the server. + inFlightCount.current--; + if (inFlightCount.current === 0) { + void queryClient.invalidateQueries({ queryKey }); + } } }, [client, projectId, queryClient], From 1c862db9ae328283e375930322adaa05eba36d2e Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 11 Jun 2026 15:44:03 +0100 Subject: [PATCH 33/37] /simplify Just a bit. Courtesy of Claude. I verified all simplifications. --- packages/core/src/scouts/scoutPrompts.ts | 42 ++++++++++++++ .../components/ConfigureAgentsSection.tsx | 16 ++--- .../scouts/components/ScoutConfigControls.tsx | 42 -------------- .../scouts/components/ScoutDetailView.tsx | 25 ++------ .../scouts/components/ScoutRowCard.tsx | 6 +- .../scouts/components/ScoutSignalsSection.tsx | 36 ++---------- .../scouts/components/ScoutTaskRunLink.tsx | 40 +++++++++++++ .../scouts/components/ScoutsFleetSection.tsx | 10 ++-- .../features/scouts/hooks/useScoutChatTask.ts | 58 +++---------------- .../scouts/hooks/useScoutRunEmissions.ts | 7 +-- .../ui/src/features/settings/settingsStore.ts | 16 +++++ 11 files changed, 134 insertions(+), 164 deletions(-) create mode 100644 packages/core/src/scouts/scoutPrompts.ts create mode 100644 packages/ui/src/features/scouts/components/ScoutTaskRunLink.tsx diff --git a/packages/core/src/scouts/scoutPrompts.ts b/packages/core/src/scouts/scoutPrompts.ts new file mode 100644 index 000000000..a476f8053 --- /dev/null +++ b/packages/core/src/scouts/scoutPrompts.ts @@ -0,0 +1,42 @@ +// Templated prompts behind the scout chat CTA chips. The agent leans on the +// exploring-signals-scouts skill from the PostHog MCP. + +export const SCOUT_FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? + +Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: + +- The fleet: which scouts exist, enabled vs disabled, and their cadences +- Recent run health: success rate, failures and timeouts, anything stuck +- Output: which scouts emitted signals recently, emit rate, signal-to-noise +- Memory: notable scratchpad entries the fleet has learned +- Recommendations: anything misconfigured, noisy, or worth tuning + +Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; + +export const SCOUT_RECENT_SIGNALS_PROMPT = `What signals have my scouts emitted recently? + +Use the exploring-signals-scouts skill from the PostHog MCP to pull the most recent scout runs that emitted findings and walk me through the signals: + +- What each signal says, in plain language +- Which scout emitted it, when, and its severity/confidence where available +- Whether it looks genuinely actionable or like noise + +Group by scout, newest first. Close with a short note on overall signal quality and any scouts that look noisy or suspiciously silent. If the skill is unavailable, fall back to the signals-scout MCP tools directly (runs list with emitted filter, run emissions).`; + +/** Per-scout variant of the templated questions, scoped to one skill. */ +export function buildScoutCheckinPrompt( + skillName: string, + displayName: string, +): string { + return `How is my ${displayName} scout performing? + +Use the exploring-signals-scouts skill from the PostHog MCP to dig into the \`${skillName}\` scout on this project: + +- Its config: enabled, cadence, dry-run posture +- Recent run history: successes, failures, timeouts, durations +- Signals it emitted recently and whether they look genuinely actionable +- Scratchpad memory the fleet holds that relates to this scout +- Whether its scope, thresholds, and schedule look right — suggest tuning if not + +Lead with a short verdict. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, run emissions, scratchpad search).`; +} diff --git a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx index 1034233b8..5456cf67a 100644 --- a/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx +++ b/packages/ui/src/features/inbox/components/ConfigureAgentsSection.tsx @@ -31,7 +31,10 @@ import { ScoutsFleetSection } from "@posthog/ui/features/scouts/components/Scout import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect"; import { GitHubIntegrationSection } from "@posthog/ui/features/settings/sections/GitHubIntegrationSection"; import { SlackInboxNotificationsSettings } from "@posthog/ui/features/settings/sections/SlackInboxNotificationsSettings"; -import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { + resolveDefaultCloudRepository, + useSettingsStore, +} from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; import { Badge } from "@posthog/ui/primitives/Badge"; import { toast } from "@posthog/ui/primitives/toast"; @@ -261,13 +264,10 @@ function SetupTaskSection() { (state) => state.lastUsedCloudRepository, ); - const setupRepository = useMemo(() => { - const normalizedLastUsed = lastUsedCloudRepository?.toLowerCase() ?? null; - if (normalizedLastUsed && repositories.includes(normalizedLastUsed)) { - return normalizedLastUsed; - } - return repositories[0] ?? null; - }, [lastUsedCloudRepository, repositories]); + const setupRepository = useMemo( + () => resolveDefaultCloudRepository(repositories, lastUsedCloudRepository), + [lastUsedCloudRepository, repositories], + ); const handleStartSetup = useCallback(async () => { if (isStartingSetupTask) return; diff --git a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx index 77e733b07..49c1009af 100644 --- a/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx +++ b/packages/ui/src/features/scouts/components/ScoutConfigControls.tsx @@ -54,48 +54,6 @@ export function ScoutEnabledSwitch({ ); } -/** - * The three per-scout controls in one horizontal strip: live vs dry-run, - * cadence, and on/off. Used in the scout detail header where there is room. - * Fleet rows show only the switch plus a gear that expands ScoutConfigForm. - */ -export function ScoutConfigControls({ - config, - onUpdate, -}: ScoutConfigControlsProps) { - const intervalOptions = useIntervalOptions(config); - - return ( - - - - - onUpdate(config.id, { emit: value === "live" }) - } - /> - - - - onUpdate(config.id, { run_interval_minutes: Number(value) }) - } - /> - - - ); -} - /** * Labeled settings form for one scout, shown when a fleet row's gear is * toggled open. Everything except enablement, which stays on the row. diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index 75f1cae87..c50f81d34 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -1,6 +1,5 @@ import { ArrowLeftIcon, - ArrowSquareOutIcon, CaretRightIcon, CompassIcon, } from "@phosphor-icons/react"; @@ -35,6 +34,7 @@ import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; import { ScoutRowCard } from "./ScoutRowCard"; import { ScoutSignalsSection } from "./ScoutSignalsSection"; +import { ScoutTaskRunLink } from "./ScoutTaskRunLink"; const FILTERS: { value: ScoutRunFilter; label: string }[] = [ { value: "all", label: "All" }, @@ -323,24 +323,11 @@ function ScoutRunListItem({ run }: { run: ScoutRun }) { {run.run_id} {taskRunUrl ? ( - - track(ANALYTICS_EVENTS.SCOUT_ACTION, { - action_type: "open_task_run", - surface: "scout_detail", - skill_name: run.skill_name, - run_id: run.run_id, - run_status: status, - }) - } - className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline hover:text-accent-12" - > - Open task run - - + ) : ( No task link available diff --git a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx index 39e8abdfc..aa56a1393 100644 --- a/packages/ui/src/features/scouts/components/ScoutRowCard.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRowCard.tsx @@ -10,6 +10,7 @@ import { type ScoutRollup, scoutSkillSlug, } from "@posthog/core/scouts/scoutPresentation"; +import { buildScoutCheckinPrompt } from "@posthog/core/scouts/scoutPrompts"; import type { ScoutSurface } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import { track } from "@posthog/ui/shell/analytics"; @@ -17,10 +18,7 @@ import { skillUrl } from "@posthog/ui/utils/posthogLinks"; import { Box, Flex, Text, Tooltip } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; import { useMemo, useState } from "react"; -import { - buildScoutCheckinPrompt, - useScoutChatTask, -} from "../hooks/useScoutChatTask"; +import { useScoutChatTask } from "../hooks/useScoutChatTask"; import type { ScoutConfigUpdate } from "../hooks/useScoutConfigMutations"; import { DryRunBadge, ScoutOriginBadge } from "./ScoutBadges"; import { ScoutConfigForm, ScoutEnabledSwitch } from "./ScoutConfigControls"; diff --git a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx index 9fedb69d6..cd5ac2241 100644 --- a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx @@ -1,4 +1,3 @@ -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; import type { ScoutRun } from "@posthog/api-client/posthog-client"; import { ANALYTICS_EVENTS } from "@posthog/shared"; import { track } from "@posthog/ui/shell/analytics"; @@ -7,6 +6,7 @@ import { Box, Flex, Text } from "@radix-ui/themes"; import { useState } from "react"; import { useScoutRunEmissions } from "../hooks/useScoutRunEmissions"; import { ScoutEmissionCard } from "./ScoutEmissionCard"; +import { ScoutTaskRunLink } from "./ScoutTaskRunLink"; /** * Cadence bounds a scout to ~48 runs per window (30-minute minimum interval), @@ -108,7 +108,9 @@ function RunEmissions({ run }: { run: ScoutRun }) { ? "Couldn't load this run's signals." : "No signal details available for this run."} - {taskRunUrl ? : null} + {taskRunUrl ? ( + + ) : null} ); } @@ -122,7 +124,7 @@ function RunEmissions({ run }: { run: ScoutRun }) { skillName={run.skill_name} footerEnd={ taskRunUrl ? ( - + ) : undefined } /> @@ -130,31 +132,3 @@ function RunEmissions({ run }: { run: ScoutRun }) { ); } - -function TaskRunLink({ - run, - taskRunUrl, -}: { - run: ScoutRun; - taskRunUrl: string; -}) { - return ( - - track(ANALYTICS_EVENTS.SCOUT_ACTION, { - action_type: "open_task_run", - surface: "scout_detail", - skill_name: run.skill_name, - run_id: run.run_id, - }) - } - className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline hover:text-accent-12" - > - Open task run - - - ); -} diff --git a/packages/ui/src/features/scouts/components/ScoutTaskRunLink.tsx b/packages/ui/src/features/scouts/components/ScoutTaskRunLink.tsx new file mode 100644 index 000000000..aafa3bb3f --- /dev/null +++ b/packages/ui/src/features/scouts/components/ScoutTaskRunLink.tsx @@ -0,0 +1,40 @@ +import { ArrowSquareOutIcon } from "@phosphor-icons/react"; +import type { ScoutRun } from "@posthog/api-client/posthog-client"; +import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { track } from "@posthog/ui/shell/analytics"; + +/** + * "Open task run" link to a scout run in PostHog Cloud, shared by the scout + * detail run list and the signals section. Callers resolve the URL (and decide + * the no-URL fallback); `runStatus` is included in analytics when known. + */ +export function ScoutTaskRunLink({ + run, + taskRunUrl, + runStatus, +}: { + run: ScoutRun; + taskRunUrl: string; + runStatus?: string; +}) { + return ( + + track(ANALYTICS_EVENTS.SCOUT_ACTION, { + action_type: "open_task_run", + surface: "scout_detail", + skill_name: run.skill_name, + run_id: run.run_id, + ...(runStatus ? { run_status: runStatus } : {}), + }) + } + className="inline-flex shrink-0 items-center gap-1 text-[11px] text-accent-11 no-underline hover:text-accent-12" + > + Open task run + + + ); +} diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index e5ab518d3..1e668cd34 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -6,6 +6,10 @@ import { getScoutOrigin, sortConfigsForDisplay, } from "@posthog/core/scouts/scoutPresentation"; +import { + SCOUT_FLEET_OVERVIEW_PROMPT, + SCOUT_RECENT_SIGNALS_PROMPT, +} from "@posthog/core/scouts/scoutPrompts"; import { SCOUT_RUNS_WINDOW_HOURS, scoutRunsWindowLabel, @@ -16,11 +20,7 @@ import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp"; import { track } from "@posthog/ui/shell/analytics"; import { Box, Flex, Text } from "@radix-ui/themes"; import { useEffect, useMemo, useRef, useState } from "react"; -import { - SCOUT_FLEET_OVERVIEW_PROMPT, - SCOUT_RECENT_SIGNALS_PROMPT, - useScoutChatTask, -} from "../hooks/useScoutChatTask"; +import { useScoutChatTask } from "../hooks/useScoutChatTask"; import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations"; import { useScoutConfigs } from "../hooks/useScoutConfigs"; import { useScoutRuns } from "../hooks/useScoutRuns"; diff --git a/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts index ecd0065af..36562ce41 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutChatTask.ts @@ -6,52 +6,13 @@ import { useInboxCloudTaskRunner, } from "@posthog/ui/features/inbox/hooks/useInboxCloudTaskRunner"; import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; -import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; +import { + resolveDefaultCloudRepository, + useSettingsStore, +} from "@posthog/ui/features/settings/settingsStore"; import { track } from "@posthog/ui/shell/analytics"; import { useCallback, useMemo } from "react"; -// Templated prompts behind the scout chat CTA chips. The agent leans on the -// exploring-signals-scouts skill from the PostHog MCP. -export const SCOUT_FLEET_OVERVIEW_PROMPT = `How is my scout fleet performing? - -Use the exploring-signals-scouts skill from the PostHog MCP to survey the signals scout fleet on this project and give me a high-level overview: - -- The fleet: which scouts exist, enabled vs disabled, and their cadences -- Recent run health: success rate, failures and timeouts, anything stuck -- Output: which scouts emitted signals recently, emit rate, signal-to-noise -- Memory: notable scratchpad entries the fleet has learned -- Recommendations: anything misconfigured, noisy, or worth tuning - -Lead with a short overall verdict, then per-scout notes only where something is notable. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, scratchpad search).`; - -export const SCOUT_RECENT_SIGNALS_PROMPT = `What signals have my scouts emitted recently? - -Use the exploring-signals-scouts skill from the PostHog MCP to pull the most recent scout runs that emitted findings and walk me through the signals: - -- What each signal says, in plain language -- Which scout emitted it, when, and its severity/confidence where available -- Whether it looks genuinely actionable or like noise - -Group by scout, newest first. Close with a short note on overall signal quality and any scouts that look noisy or suspiciously silent. If the skill is unavailable, fall back to the signals-scout MCP tools directly (runs list with emitted filter, run emissions).`; - -/** Per-scout variant of the templated questions, scoped to one skill. */ -export function buildScoutCheckinPrompt( - skillName: string, - displayName: string, -): string { - return `How is my ${displayName} scout performing? - -Use the exploring-signals-scouts skill from the PostHog MCP to dig into the \`${skillName}\` scout on this project: - -- Its config: enabled, cadence, dry-run posture -- Recent run history: successes, failures, timeouts, durations -- Signals it emitted recently and whether they look genuinely actionable -- Scratchpad memory the fleet holds that relates to this scout -- Whether its scope, thresholds, and schedule look right — suggest tuning if not - -Lead with a short verdict. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, run emissions, scratchpad search).`; -} - interface UseScoutChatTaskOptions { /** The templated question the task is created with. */ prompt: string; @@ -93,13 +54,10 @@ export function useScoutChatTask({ (state) => state.lastUsedCloudRepository, ); - const cloudRepository = useMemo(() => { - const normalizedLastUsed = lastUsedCloudRepository?.toLowerCase() ?? null; - if (normalizedLastUsed && repositories.includes(normalizedLastUsed)) { - return normalizedLastUsed; - } - return repositories[0] ?? null; - }, [lastUsedCloudRepository, repositories]); + const cloudRepository = useMemo( + () => resolveDefaultCloudRepository(repositories, lastUsedCloudRepository), + [lastUsedCloudRepository, repositories], + ); const buildInput = useCallback( (ctx: InboxCloudTaskInputContext): TaskCreationInput => ({ diff --git a/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts b/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts index 1e5049d5b..0a44eeee0 100644 --- a/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts +++ b/packages/ui/src/features/scouts/hooks/useScoutRunEmissions.ts @@ -3,10 +3,7 @@ import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useAuthStateValue } from "../../auth/store"; import { scoutQueryKeys } from "./scoutQueryKeys"; -export function useScoutRunEmissions( - runId: string, - options?: { enabled?: boolean }, -) { +export function useScoutRunEmissions(runId: string) { const projectId = useAuthStateValue((state) => state.currentProjectId); return useAuthenticatedQuery( scoutQueryKeys.emissions(projectId, runId), @@ -15,7 +12,7 @@ export function useScoutRunEmissions( ? client.listScoutRunEmissions(projectId, runId) : Promise.resolve([]), { - enabled: !!projectId && !!runId && (options?.enabled ?? true), + enabled: !!projectId && !!runId, staleTime: 60_000, }, ); diff --git a/packages/ui/src/features/settings/settingsStore.ts b/packages/ui/src/features/settings/settingsStore.ts index ddf2c314a..ea2600f85 100644 --- a/packages/ui/src/features/settings/settingsStore.ts +++ b/packages/ui/src/features/settings/settingsStore.ts @@ -326,3 +326,19 @@ export const useSettingsStore = create()( }, ), ); + +/** + * The repository a one-click cloud task should default to: the last-used cloud + * repository when it's still connected, otherwise the first connected one. + * `repositories` is expected to be normalized (lowercased) already. + */ +export function resolveDefaultCloudRepository( + repositories: string[], + lastUsedCloudRepository: string | null, +): string | null { + const normalizedLastUsed = lastUsedCloudRepository?.toLowerCase() ?? null; + if (normalizedLastUsed && repositories.includes(normalizedLastUsed)) { + return normalizedLastUsed; + } + return repositories[0] ?? null; +} From 978e125dd44e820c51636b792851b73f0dda37a3 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 11 Jun 2026 15:47:35 +0100 Subject: [PATCH 34/37] =?UTF-8?q?Em-dash=20=E2=86=92=C2=A0=20en-dash?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit De-LLM-ifying. --- packages/core/src/scouts/scoutPrompts.ts | 2 +- .../ui/src/features/scouts/components/ScoutDetailView.tsx | 2 +- packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx | 2 +- .../ui/src/features/scouts/components/ScoutSignalsSection.tsx | 2 +- .../ui/src/features/scouts/components/ScoutsFleetSection.tsx | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core/src/scouts/scoutPrompts.ts b/packages/core/src/scouts/scoutPrompts.ts index a476f8053..796bd2653 100644 --- a/packages/core/src/scouts/scoutPrompts.ts +++ b/packages/core/src/scouts/scoutPrompts.ts @@ -36,7 +36,7 @@ Use the exploring-signals-scouts skill from the PostHog MCP to dig into the \`${ - Recent run history: successes, failures, timeouts, durations - Signals it emitted recently and whether they look genuinely actionable - Scratchpad memory the fleet holds that relates to this scout -- Whether its scope, thresholds, and schedule look right — suggest tuning if not +- Whether its scope, thresholds, and schedule look right – suggest tuning if not Lead with a short verdict. If the skill is unavailable, fall back to the signals-scout MCP tools directly (config list, runs list, run emissions, scratchpad search).`; } diff --git a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx index c50f81d34..408892400 100644 --- a/packages/ui/src/features/scouts/components/ScoutDetailView.tsx +++ b/packages/ui/src/features/scouts/components/ScoutDetailView.tsx @@ -224,7 +224,7 @@ export function ScoutDetailView({ skillSlug }: { skillSlug: string }) { {scoutRuns.length > 0 ? `No runs match this filter in the ${scoutRunsWindowLabel(runsWindow)}.` : runsWindow && !runsWindow.complete - ? `No runs fetched in the last ${SCOUT_RUNS_WINDOW_HOURS} hours — the fleet window was truncated before it could cover this scout, so runs may exist beyond what was fetched.` + ? `No runs fetched in the last ${SCOUT_RUNS_WINDOW_HOURS} hours – the fleet window was truncated before it could cover this scout, so runs may exist beyond what was fetched.` : `No runs in the ${scoutRunsWindowLabel(runsWindow)}.`} ) : ( diff --git a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx index 09c969196..88028e17c 100644 --- a/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx +++ b/packages/ui/src/features/scouts/components/ScoutRunBoxes.tsx @@ -11,7 +11,7 @@ import { getPostHogUrl } from "@posthog/ui/utils/urls"; import { Flex, Text, Tooltip } from "@radix-ui/themes"; // Quiet is the common, healthy baseline so it recedes to gray; saturated -// color only means something happened — iris payoff, red/amber trouble. +// color only means something happened – iris payoff, red/amber trouble. const OUTCOME_BOX_CLASS: Record = { emitted: "bg-(--iris-9)", quiet: "bg-(--gray-5)", diff --git a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx index cd5ac2241..41eb42b03 100644 --- a/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutSignalsSection.tsx @@ -17,7 +17,7 @@ const INITIAL_EMITTED_RUNS = 10; /** * The signals this scout emitted in the runs window, newest first. Emissions - * are only fetchable per run, so each emitted run gets its own child query — + * are only fetchable per run, so each emitted run gets its own child query – * runs beyond the cap stay unmounted (and unfetched) until "Show more". */ export function ScoutSignalsSection({ diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 1e668cd34..0ae88e10e 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -54,7 +54,7 @@ export function ScoutsFleetSection() { ); } - // A failed request must not masquerade as an empty fleet — a missing scope + // A failed request must not masquerade as an empty fleet – a missing scope // or regional rollout gap would otherwise be indistinguishable from // "no scouts yet". if (isError) { @@ -239,7 +239,7 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { /** * Suggestion-chip CTA that fires an auto-mode cloud task asking the - * exploring-signals-scouts skill a templated question, then navigates to it — + * exploring-signals-scouts skill a templated question, then navigates to it – * same one-click shape as the inbox discuss / create-PR flows. */ function ScoutChatCta({ From e426eae9b080f96930c804a3731c957b57f7967f Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 11 Jun 2026 16:18:33 +0100 Subject: [PATCH 35/37] Fix a lowercase --- .../ui/src/features/scouts/components/ScoutsFleetSection.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx index 0ae88e10e..24500c640 100644 --- a/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx +++ b/packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx @@ -162,7 +162,7 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) { {summary.runningCount > 0 ? `${summary.runningCount} running now` - : "none running now"} + : "None running now"} {summary.successRate !== null ? ` · ${Math.round(summary.successRate * 100)}% success` : ""} From 6051a85a296d6262037832790b265dbc1eba5f90 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 11 Jun 2026 16:20:37 +0100 Subject: [PATCH 36/37] Show skills in Skills --- .../components/ScoutHelperSkillLinks.tsx | 39 +++++++++---------- packages/ui/src/features/skills/SkillCard.tsx | 26 ++++++++++++- .../ui/src/features/skills/SkillsView.tsx | 25 +++++++++++- .../features/skills/skillsSelectionStore.ts | 31 +++++++++++++++ 4 files changed, 98 insertions(+), 23 deletions(-) create mode 100644 packages/ui/src/features/skills/skillsSelectionStore.ts diff --git a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx index 52e1abee6..d2a543025 100644 --- a/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx +++ b/packages/ui/src/features/scouts/components/ScoutHelperSkillLinks.tsx @@ -1,44 +1,41 @@ -import { ArrowSquareOutIcon } from "@phosphor-icons/react"; import type { ScoutSurface } from "@posthog/shared"; import { ANALYTICS_EVENTS } from "@posthog/shared"; +import { useSkillsSelectionActions } from "@posthog/ui/features/skills/skillsSelectionStore"; import { track } from "@posthog/ui/shell/analytics"; import { Text } from "@radix-ui/themes"; +import { Link } from "@tanstack/react-router"; +// The two official scout helper skills are bundled with the PostHog plugin, so +// they open in the in-app Skills view rather than linking out to GitHub. `name` +// is the skill's frontmatter name, used to select it once Skills loads. const HELPER_SKILLS = [ - { - label: "authoring scouts", - href: "https://github.com/PostHog/ai-plugin/tree/main/skills/authoring-signals-scouts", - }, - { - label: "exploring scouts", - href: "https://github.com/PostHog/ai-plugin/tree/main/skills/exploring-signals-scouts", - }, + { label: "authoring scouts", name: "authoring-signals-scouts" }, + { label: "exploring scouts", name: "exploring-signals-scouts" }, ]; -/** One-line pointer to the two official scout helper skills on GitHub. */ +/** One-line pointer to the two official scout helper skills, opened in-app. */ export function ScoutHelperSkillLinks({ surface }: { surface: ScoutSurface }) { + const { requestSkill } = useSkillsSelectionActions(); return ( Helper skills:{" "} {HELPER_SKILLS.map((skill, index) => ( - + {index > 0 ? " · " : null} - + { track(ANALYTICS_EVENTS.SCOUT_ACTION, { action_type: "open_helper_skill", surface, helper_skill: skill.label, - }) - } - className="inline-flex items-center gap-0.5 text-accent-11 no-underline hover:text-accent-12" + }); + requestSkill(skill.name); + }} + className="text-accent-11 no-underline hover:text-accent-12" > {skill.label} - - + ))} diff --git a/packages/ui/src/features/skills/SkillCard.tsx b/packages/ui/src/features/skills/SkillCard.tsx index 7a2afe6f2..840421b44 100644 --- a/packages/ui/src/features/skills/SkillCard.tsx +++ b/packages/ui/src/features/skills/SkillCard.tsx @@ -1,6 +1,7 @@ import { Folder, Package, Storefront, User } from "@phosphor-icons/react"; import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Badge, Box, Flex, Text } from "@radix-ui/themes"; +import { useEffect, useRef } from "react"; export const SOURCE_CONFIG: Record< SkillSource, @@ -24,14 +25,31 @@ interface SkillCardProps { skill: SkillInfo; isSelected: boolean; onClick: () => void; + /** When true, scroll this card into view once (used for deep-linked skills). */ + scrollIntoView?: boolean; + onScrolledIntoView?: () => void; } -export function SkillCard({ skill, isSelected, onClick }: SkillCardProps) { +export function SkillCard({ + skill, + isSelected, + onClick, + scrollIntoView, + onScrolledIntoView, +}: SkillCardProps) { const config = SOURCE_CONFIG[skill.source]; const Icon = config?.icon ?? Package; + const ref = useRef(null); + useEffect(() => { + if (!scrollIntoView) return; + ref.current?.scrollIntoView({ block: "center" }); + onScrolledIntoView?.(); + }, [scrollIntoView, onScrolledIntoView]); + return ( void; + scrollToPath: string | null; + onScrolledIntoView: () => void; } export function SkillSection({ @@ -79,6 +99,8 @@ export function SkillSection({ skills, selectedPath, onSelect, + scrollToPath, + onScrolledIntoView, }: SkillSectionProps) { return ( @@ -92,6 +114,8 @@ export function SkillSection({ skill={skill} isSelected={selectedPath === skill.path} onClick={() => onSelect(skill.path)} + scrollIntoView={scrollToPath === skill.path} + onScrolledIntoView={onScrolledIntoView} /> ))} diff --git a/packages/ui/src/features/skills/SkillsView.tsx b/packages/ui/src/features/skills/SkillsView.tsx index 04e7cce3a..242867a90 100644 --- a/packages/ui/src/features/skills/SkillsView.tsx +++ b/packages/ui/src/features/skills/SkillsView.tsx @@ -1,11 +1,15 @@ import { Lightbulb, MagnifyingGlass } from "@phosphor-icons/react"; import type { SkillInfo, SkillSource } from "@posthog/shared"; import { Box, Flex, ScrollArea, Text, TextField } from "@radix-ui/themes"; -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useSetHeaderContent } from "../../hooks/useSetHeaderContent"; import { ResizableSidebar } from "../../primitives/ResizableSidebar"; import { SkillSection, SOURCE_CONFIG } from "./SkillCard"; import { SkillDetailPanel } from "./SkillDetailPanel"; +import { + useRequestedSkillName, + useSkillsSelectionActions, +} from "./skillsSelectionStore"; import { useSkillsSidebarStore } from "./skillsSidebarStore"; import { useSkills } from "./useSkills"; @@ -15,6 +19,7 @@ export function SkillsView() { const { data: skills = [], isLoading } = useSkills(); const [selectedPath, setSelectedPath] = useState(null); + const [scrollToPath, setScrollToPath] = useState(null); const [searchQuery, setSearchQuery] = useState(""); const { @@ -33,6 +38,22 @@ export function SkillsView() { setSelectedPath((prev) => (prev === path ? null : path)); }, []); + // Another surface (e.g. the scout helper links) can ask to open a specific + // skill by name; honor it once the skill list has loaded, then clear it. + const requestedSkillName = useRequestedSkillName(); + const { clearRequestedSkill } = useSkillsSelectionActions(); + useEffect(() => { + if (!requestedSkillName || skills.length === 0) return; + const match = skills.find((s) => s.name === requestedSkillName); + if (match) { + setSelectedPath(match.path); + setScrollToPath(match.path); + } + clearRequestedSkill(); + }, [requestedSkillName, skills, clearRequestedSkill]); + + const handleScrolledIntoView = useCallback(() => setScrollToPath(null), []); + const handleCloseSidebar = useCallback(() => { setSelectedPath(null); }, []); @@ -127,6 +148,8 @@ export function SkillsView() { skills={items} selectedPath={selectedSkill?.path ?? null} onSelect={handleSelect} + scrollToPath={scrollToPath} + onScrolledIntoView={handleScrolledIntoView} /> ); })} diff --git a/packages/ui/src/features/skills/skillsSelectionStore.ts b/packages/ui/src/features/skills/skillsSelectionStore.ts new file mode 100644 index 000000000..5a6c7c792 --- /dev/null +++ b/packages/ui/src/features/skills/skillsSelectionStore.ts @@ -0,0 +1,31 @@ +import { create } from "zustand"; + +interface SkillsSelectionState { + /** + * A skill name another surface asked to open (by frontmatter `name`). + * SkillsView consumes it once on load to select the matching skill, then + * clears it so a later plain visit to /skills opens nothing. + */ + requestedSkillName: string | null; +} + +interface SkillsSelectionActions { + requestSkill: (name: string) => void; + clearRequestedSkill: () => void; +} + +type SkillsSelectionStore = SkillsSelectionState & { + actions: SkillsSelectionActions; +}; + +const useStore = create((set) => ({ + requestedSkillName: null, + actions: { + requestSkill: (name) => set({ requestedSkillName: name }), + clearRequestedSkill: () => set({ requestedSkillName: null }), + }, +})); + +export const useRequestedSkillName = () => + useStore((s) => s.requestedSkillName); +export const useSkillsSelectionActions = () => useStore((s) => s.actions); From d67eea3ae94d877417606e168a80e29ce9c83609 Mon Sep 17 00:00:00 2001 From: Michael Matloka Date: Thu, 11 Jun 2026 16:23:02 +0100 Subject: [PATCH 37/37] Fix navbar highlight --- packages/ui/src/router/useAppView.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/ui/src/router/useAppView.ts b/packages/ui/src/router/useAppView.ts index 2c3d0d37e..daa24a14d 100644 --- a/packages/ui/src/router/useAppView.ts +++ b/packages/ui/src/router/useAppView.ts @@ -72,6 +72,12 @@ function deriveFromMatches(matches: Match[]): AppView { if (last.routeId.startsWith("/code/inbox")) { return { type: "inbox" }; } + // /code/agents is now an Outlet layout; the view lives at the index + // child (/code/agents/) and scout detail routes nest deeper, so match + // the whole subtree rather than only the bare layout route. + if (last.routeId.startsWith("/code/agents")) { + return { type: "agents" }; + } return { type: "task-input" }; } }