Skip to content

Commit 6830391

Browse files
authored
feat(scouts): filter the fleet list by creator (#3292)
## Problem The scouts fleet list shows every scout on the project. On a shared project with a large fleet (canonical scouts plus many teammates' custom ones), there's no way to narrow the list to the scouts a given person authored — most commonly your own. ## Changes - Adds a **Created by** picker next to **Hide disabled** in the fleet list — "Any user" plus the fleet's distinct authors, with the current user pinned first (and always offered, so "just mine" is one click). Same filter shape as the cloud skills list's Created-by member filter. - The configs endpoint carries no creator, so authorship is joined client-side from the backing `signals-scout-*` skills (`listLlmSkills` gained a `category` option; the fleet fetches `?category=scout` once). Canonical seeds carry no `created_by`, so they never match a person. As on the cloud skills list, "creator" means the latest version's author. - Splits the fleet list into a pure `ScoutsFleetListView` (data via props) with Storybook stories for the default fleet, filtered-to-you, filtered-empty, and skills-API-gated states; the picker hides entirely when authorship is unknowable. - New pure helpers in core (`buildScoutCreatorIndex`, `listScoutCreatorOptions`, `scoutCreatorKey`) with unit tests, and a `filter_created_by` scout analytics action. The picker, open (Storybook, fixture data): <img src="https://raw.githubusercontent.com/PostHog/code/37382bf9493edeb93b6499e70c61b8a5108293f5/.github/pr-assets/scouts-fleet-picker-open.png" width="900" alt="Scouts fleet list with the Created by picker open, showing Any user, the current user pinned first, and two other authors"> Filtered to the current user: <img src="https://raw.githubusercontent.com/PostHog/code/37382bf9493edeb93b6499e70c61b8a5108293f5/.github/pr-assets/scouts-fleet-filtered.png" width="900" alt="Scouts fleet list filtered to the current user, showing only their scout"> (Screenshots were committed once for hosting and removed again in the next commit — the merged tree carries no binaries.) **Why**: when viewing the list of scouts, you generally want to be able to select just the ones you (or a teammate) created. ## How did you test this? - Unit tests for the creator helpers and option builder in `scoutPresentation.test.ts` (`pnpm --filter @posthog/core test` — 2172 passed). - `pnpm turbo typecheck` for `shared`, `api-client`, `core`, and `ui`, plus `biome check` on the touched files — all clean. - Rendered the new Storybook stories in the dev server and captured the screenshots above via headless Chromium (no page errors). - Verified against the live backend serializers that `?category=scout` filtering and `created_by` on the skills list exist, and that canonical seeds have no author. ## Automatic notifications - [ ] Publish to changelog? - [ ] Alert Sales and Marketing teams? --- *Created with [PostHog Code](https://posthog.com/code?ref=pr)*
1 parent c9c5637 commit 6830391

8 files changed

Lines changed: 604 additions & 14 deletions

File tree

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

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4698,11 +4698,18 @@ export class PostHogAPIClient {
46984698
* Lists the team's LLM skills (latest versions, no bodies).
46994699
* Returns null when the feature is unavailable for this org (the
47004700
* llm-analytics-skills flag gates the endpoint server-side with a 403).
4701+
* `category` narrows to one exact server-owned category (e.g. "scout"
4702+
* for Signals scouts); omit it to list every category.
47014703
*/
4702-
async listLlmSkills(): Promise<LlmSkillListItem[] | null> {
4704+
async listLlmSkills(
4705+
options: { category?: string } = {},
4706+
): Promise<LlmSkillListItem[] | null> {
47034707
const teamId = await this.getTeamId();
47044708
const urlPath = `/api/environments/${teamId}/llm_skills/`;
47054709
const url = new URL(`${this.api.baseUrl}${urlPath}`);
4710+
if (options.category !== undefined) {
4711+
url.searchParams.set("category", options.category);
4712+
}
47064713
const response = await this.api.fetcher.fetch({
47074714
method: "get",
47084715
url,

packages/core/src/scouts/scoutPresentation.test.ts

Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client";
22
import { describe, expect, it } from "vitest";
33
import {
4+
buildScoutCreatorIndex,
45
computeFleetSummary,
56
computeScoutRollups,
67
deriveRunFailureKind,
@@ -10,12 +11,16 @@ import {
1011
formatRunIntervalShort,
1112
getScoutOrigin,
1213
isRunStuck,
14+
isScoutCreatedByUser,
15+
listScoutCreatorOptions,
1316
normalizeRunStatus,
1417
prettifyScoutSkillName,
1518
runDurationSeconds,
1619
runMatchesFilter,
1720
type ScoutOrigin,
1821
type ScoutRunFilter,
22+
scoutCreatorDisplayName,
23+
scoutCreatorKey,
1924
scoutRunOutcomeLabel,
2025
scoutSkillNameFromSlug,
2126
scoutSkillSlug,
@@ -329,3 +334,164 @@ describe("intervals and ordering", () => {
329334
]);
330335
});
331336
});
337+
338+
describe("creators", () => {
339+
it("indexes latest authored skills and skips canonical seeds", () => {
340+
const index = buildScoutCreatorIndex([
341+
{
342+
name: "signals-scout-ad-spend",
343+
created_by: { id: 7, email: "paul@example.com" },
344+
is_latest: true,
345+
},
346+
// Canonical seeds carry no author.
347+
{
348+
name: "signals-scout-error-tracking",
349+
created_by: null,
350+
is_latest: true,
351+
},
352+
// Superseded versions must not shadow the latest author.
353+
{
354+
name: "signals-scout-ad-spend",
355+
created_by: { id: 9, email: "someone@example.com" },
356+
is_latest: false,
357+
},
358+
]);
359+
expect(index.get("signals-scout-ad-spend")).toEqual({
360+
id: 7,
361+
email: "paul@example.com",
362+
});
363+
expect(index.has("signals-scout-error-tracking")).toBe(false);
364+
});
365+
366+
it.each<{
367+
label: string;
368+
creator: Parameters<typeof isScoutCreatedByUser>[0];
369+
user: Parameters<typeof isScoutCreatedByUser>[1];
370+
expected: boolean;
371+
}>([
372+
{
373+
label: "matches on numeric id",
374+
creator: { id: 7, email: "old@example.com" },
375+
user: { id: 7, email: "new@example.com" },
376+
expected: true,
377+
},
378+
{
379+
label: "rejects a different id even when emails collide",
380+
creator: { id: 7, email: "shared@example.com" },
381+
user: { id: 8, email: "shared@example.com" },
382+
expected: false,
383+
},
384+
{
385+
label: "falls back to case-insensitive email when the id is absent",
386+
creator: { email: "Paul@Example.com" },
387+
user: { id: 7, email: "paul@example.com" },
388+
expected: true,
389+
},
390+
{
391+
label: "never matches an unauthored scout",
392+
creator: undefined,
393+
user: { id: 7, email: "paul@example.com" },
394+
expected: false,
395+
},
396+
{
397+
label: "never matches without a user",
398+
creator: { id: 7 },
399+
user: null,
400+
expected: false,
401+
},
402+
{
403+
label: "never matches on missing emails",
404+
creator: { email: null },
405+
user: { email: "" },
406+
expected: false,
407+
},
408+
])("$label", ({ creator, user, expected }) => {
409+
expect(isScoutCreatedByUser(creator, user)).toBe(expected);
410+
});
411+
412+
it("keys creators by numeric id, falling back to normalized email", () => {
413+
expect(scoutCreatorKey({ id: 7, email: "x@example.com" })).toBe("id:7");
414+
expect(scoutCreatorKey({ email: " Paul@Example.com " })).toBe(
415+
"email:paul@example.com",
416+
);
417+
expect(scoutCreatorKey({})).toBeNull();
418+
expect(scoutCreatorKey(null)).toBeNull();
419+
});
420+
421+
it("prefers the full name for display, then the email", () => {
422+
expect(
423+
scoutCreatorDisplayName({
424+
first_name: "Paul",
425+
last_name: "Smith",
426+
email: "p@example.com",
427+
}),
428+
).toBe("Paul Smith");
429+
expect(scoutCreatorDisplayName({ email: "p@example.com" })).toBe(
430+
"p@example.com",
431+
);
432+
expect(scoutCreatorDisplayName({})).toBe("Unknown user");
433+
});
434+
435+
describe("listScoutCreatorOptions", () => {
436+
const index = buildScoutCreatorIndex([
437+
{
438+
name: "signals-scout-ad-spend",
439+
created_by: { id: 7, first_name: "Paul", email: "paul@example.com" },
440+
is_latest: true,
441+
},
442+
{
443+
name: "signals-scout-checkout",
444+
created_by: { id: 9, first_name: "Zoe", email: "zoe@example.com" },
445+
is_latest: true,
446+
},
447+
{
448+
name: "signals-scout-digest",
449+
created_by: { id: 8, first_name: "Amy", email: "amy@example.com" },
450+
is_latest: true,
451+
},
452+
// A second skill by an existing author must not duplicate the option.
453+
{
454+
name: "signals-scout-uptime",
455+
created_by: { id: 9, first_name: "Zoe", email: "zoe@example.com" },
456+
is_latest: true,
457+
},
458+
]);
459+
460+
it("pins the current user first and sorts the rest alphabetically", () => {
461+
const options = listScoutCreatorOptions(index, {
462+
id: 7,
463+
email: "paul@example.com",
464+
});
465+
expect(options.map((option) => option.label)).toEqual([
466+
"Paul (you)",
467+
"Amy",
468+
"Zoe",
469+
]);
470+
expect(options[0]).toMatchObject({ key: "id:7", isCurrentUser: true });
471+
});
472+
473+
it("still offers the current user when they authored nothing", () => {
474+
const options = listScoutCreatorOptions(index, {
475+
id: 1,
476+
first_name: "New",
477+
email: "new@example.com",
478+
});
479+
expect(options[0]).toEqual({
480+
key: "id:1",
481+
label: "New (you)",
482+
isCurrentUser: true,
483+
});
484+
expect(options).toHaveLength(4);
485+
});
486+
487+
it("lists plain authors when the current user is unknown", () => {
488+
const options = listScoutCreatorOptions(index, null);
489+
expect(options.map((option) => option.label)).toEqual([
490+
"Amy",
491+
"Paul",
492+
"Zoe",
493+
]);
494+
expect(options.every((option) => !option.isCurrentUser)).toBe(true);
495+
});
496+
});
497+
});

packages/core/src/scouts/scoutPresentation.ts

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import type { ScoutConfig, ScoutRun } from "@posthog/api-client/posthog-client";
1+
import type {
2+
LlmSkillCreatedBy,
3+
LlmSkillListItem,
4+
ScoutConfig,
5+
ScoutRun,
6+
} from "@posthog/api-client/posthog-client";
27

38
// Single source of truth lives in `@posthog/shared` so `buildScoutDeeplink`
49
// (which cannot import core) and the UI share one slug implementation.
@@ -27,6 +32,117 @@ export function prettifyScoutSkillName(skillName: string): string {
2732
return cleaned.charAt(0).toUpperCase() + cleaned.slice(1);
2833
}
2934

35+
/** Skill name → author of the backing `signals-scout-*` skill's latest version. */
36+
export type ScoutCreatorIndex = Map<string, LlmSkillCreatedBy>;
37+
38+
/**
39+
* The configs endpoint carries no creator, so authorship comes from the
40+
* backing skill (the scout IS the skill). Canonical seeds are created with no
41+
* `created_by`, so absence from the index means "not hand-authored by anyone".
42+
*/
43+
export function buildScoutCreatorIndex(
44+
skills: Pick<LlmSkillListItem, "name" | "created_by" | "is_latest">[],
45+
): ScoutCreatorIndex {
46+
const index: ScoutCreatorIndex = new Map();
47+
for (const skill of skills) {
48+
if (!skill.is_latest || !skill.created_by) continue;
49+
index.set(skill.name, skill.created_by);
50+
}
51+
return index;
52+
}
53+
54+
/** The slice of the current user the creator filter needs. */
55+
export interface ScoutCreatorUser {
56+
id?: number;
57+
email?: string | null;
58+
first_name?: string | null;
59+
last_name?: string | null;
60+
}
61+
62+
export function isScoutCreatedByUser(
63+
creator: LlmSkillCreatedBy | null | undefined,
64+
user: ScoutCreatorUser | null | undefined,
65+
): boolean {
66+
if (!creator || !user) return false;
67+
if (creator.id !== undefined && user.id !== undefined) {
68+
return creator.id === user.id;
69+
}
70+
// Older payloads may omit the numeric id; emails are unique per instance.
71+
const creatorEmail = creator.email?.trim().toLowerCase();
72+
const userEmail = user.email?.trim().toLowerCase();
73+
return !!creatorEmail && creatorEmail === userEmail;
74+
}
75+
76+
export function scoutCreatorDisplayName(
77+
creator: Pick<LlmSkillCreatedBy, "first_name" | "last_name" | "email">,
78+
): string {
79+
const name = [creator.first_name, creator.last_name]
80+
.filter(Boolean)
81+
.join(" ")
82+
.trim();
83+
return name || creator.email?.trim() || "Unknown user";
84+
}
85+
86+
/**
87+
* Stable identity for a creator across the option list and the per-config
88+
* lookup: the numeric user id when present, else the lowercased email.
89+
*/
90+
export function scoutCreatorKey(
91+
creator: Pick<LlmSkillCreatedBy, "id" | "email"> | null | undefined,
92+
): string | null {
93+
if (!creator) return null;
94+
if (typeof creator.id === "number") return `id:${creator.id}`;
95+
const email = creator.email?.trim().toLowerCase();
96+
return email ? `email:${email}` : null;
97+
}
98+
99+
export interface ScoutCreatorOption {
100+
key: string;
101+
label: string;
102+
isCurrentUser: boolean;
103+
}
104+
105+
/**
106+
* Distinct authors across the fleet for a "Created by" picker: the current
107+
* user pinned first (offered even with nothing authored yet, so "just mine"
108+
* is always selectable), then the other authors A–Z. Canonical seeds carry no
109+
* author, so they never contribute an option.
110+
*/
111+
export function listScoutCreatorOptions(
112+
index: ScoutCreatorIndex,
113+
currentUser: ScoutCreatorUser | null | undefined,
114+
): ScoutCreatorOption[] {
115+
const byKey = new Map<string, ScoutCreatorOption>();
116+
for (const creator of index.values()) {
117+
const key = scoutCreatorKey(creator);
118+
if (!key || byKey.has(key)) continue;
119+
const isCurrentUser = isScoutCreatedByUser(creator, currentUser);
120+
byKey.set(key, {
121+
key,
122+
label: isCurrentUser
123+
? `${scoutCreatorDisplayName(creator)} (you)`
124+
: scoutCreatorDisplayName(creator),
125+
isCurrentUser,
126+
});
127+
}
128+
const options = [...byKey.values()];
129+
if (currentUser && !options.some((option) => option.isCurrentUser)) {
130+
const key = scoutCreatorKey(currentUser);
131+
if (key) {
132+
options.push({
133+
key,
134+
label: `${scoutCreatorDisplayName(currentUser)} (you)`,
135+
isCurrentUser: true,
136+
});
137+
}
138+
}
139+
options.sort((a, b) => {
140+
if (a.isCurrentUser !== b.isCurrentUser) return a.isCurrentUser ? -1 : 1;
141+
return a.label.localeCompare(b.label);
142+
});
143+
return options;
144+
}
145+
30146
export type ScoutRunStatus =
31147
| "completed"
32148
| "failed"

packages/shared/src/analytics-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,7 @@ export type ScoutActionType =
717717
| "show_more_emitted_runs"
718718
| "filter_runs"
719719
| "toggle_hide_disabled"
720+
| "filter_created_by"
720721
| "open_settings"
721722
| "close_settings"
722723
| "open_findings"
@@ -775,6 +776,7 @@ export interface ScoutActionProperties {
775776
filter_match_count?: number;
776777
helper_skill?: string;
777778
hide_disabled?: boolean;
779+
created_by_me?: boolean;
778780
/** Status of the linked inbox report, for `open_linked_report`. */
779781
report_status?: string;
780782
}

0 commit comments

Comments
 (0)