Skip to content

Commit efeb5bf

Browse files
authored
feat(scouts): add "Created by me" filter to the fleet list
The scout configs endpoint carries no creator, so authorship is joined client-side from the backing signals-scout-* skills (?category=scout on the llm_skills list). Canonical seeds have no created_by, so only hand-authored scouts can match. The toggle sits next to Hide disabled and is hidden when the skills API is unavailable for the org. Generated-By: PostHog Code Task-Id: dd4f5437-2451-4783-b648-c0fad872f150
1 parent 61b57e7 commit efeb5bf

7 files changed

Lines changed: 225 additions & 13 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: 77 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,6 +11,7 @@ import {
1011
formatRunIntervalShort,
1112
getScoutOrigin,
1213
isRunStuck,
14+
isScoutCreatedByUser,
1315
normalizeRunStatus,
1416
prettifyScoutSkillName,
1517
runDurationSeconds,
@@ -329,3 +331,78 @@ describe("intervals and ordering", () => {
329331
]);
330332
});
331333
});
334+
335+
describe("creators", () => {
336+
it("indexes latest authored skills and skips canonical seeds", () => {
337+
const index = buildScoutCreatorIndex([
338+
{
339+
name: "signals-scout-ad-spend",
340+
created_by: { id: 7, email: "paul@example.com" },
341+
is_latest: true,
342+
},
343+
// Canonical seeds carry no author.
344+
{
345+
name: "signals-scout-error-tracking",
346+
created_by: null,
347+
is_latest: true,
348+
},
349+
// Superseded versions must not shadow the latest author.
350+
{
351+
name: "signals-scout-ad-spend",
352+
created_by: { id: 9, email: "someone@example.com" },
353+
is_latest: false,
354+
},
355+
]);
356+
expect(index.get("signals-scout-ad-spend")).toEqual({
357+
id: 7,
358+
email: "paul@example.com",
359+
});
360+
expect(index.has("signals-scout-error-tracking")).toBe(false);
361+
});
362+
363+
it.each<{
364+
label: string;
365+
creator: Parameters<typeof isScoutCreatedByUser>[0];
366+
user: Parameters<typeof isScoutCreatedByUser>[1];
367+
expected: boolean;
368+
}>([
369+
{
370+
label: "matches on numeric id",
371+
creator: { id: 7, email: "old@example.com" },
372+
user: { id: 7, email: "new@example.com" },
373+
expected: true,
374+
},
375+
{
376+
label: "rejects a different id even when emails collide",
377+
creator: { id: 7, email: "shared@example.com" },
378+
user: { id: 8, email: "shared@example.com" },
379+
expected: false,
380+
},
381+
{
382+
label: "falls back to case-insensitive email when the id is absent",
383+
creator: { email: "Paul@Example.com" },
384+
user: { id: 7, email: "paul@example.com" },
385+
expected: true,
386+
},
387+
{
388+
label: "never matches an unauthored scout",
389+
creator: undefined,
390+
user: { id: 7, email: "paul@example.com" },
391+
expected: false,
392+
},
393+
{
394+
label: "never matches without a user",
395+
creator: { id: 7 },
396+
user: null,
397+
expected: false,
398+
},
399+
{
400+
label: "never matches on missing emails",
401+
creator: { email: null },
402+
user: { email: "" },
403+
expected: false,
404+
},
405+
])("$label", ({ creator, user, expected }) => {
406+
expect(isScoutCreatedByUser(creator, user)).toBe(expected);
407+
});
408+
});

packages/core/src/scouts/scoutPresentation.ts

Lines changed: 39 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,39 @@ 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+
export function isScoutCreatedByUser(
55+
creator: LlmSkillCreatedBy | null | undefined,
56+
user: { id?: number; email?: string | null } | null | undefined,
57+
): boolean {
58+
if (!creator || !user) return false;
59+
if (creator.id !== undefined && user.id !== undefined) {
60+
return creator.id === user.id;
61+
}
62+
// Older payloads may omit the numeric id; emails are unique per instance.
63+
const creatorEmail = creator.email?.trim().toLowerCase();
64+
const userEmail = user.email?.trim().toLowerCase();
65+
return !!creatorEmail && creatorEmail === userEmail;
66+
}
67+
3068
export type ScoutRunStatus =
3169
| "completed"
3270
| "failed"

packages/shared/src/analytics-events.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -716,6 +716,7 @@ export type ScoutActionType =
716716
| "show_more_emitted_runs"
717717
| "filter_runs"
718718
| "toggle_hide_disabled"
719+
| "toggle_created_by_me"
719720
| "open_settings"
720721
| "close_settings"
721722
| "open_findings"
@@ -774,6 +775,7 @@ export interface ScoutActionProperties {
774775
filter_match_count?: number;
775776
helper_skill?: string;
776777
hide_disabled?: boolean;
778+
created_by_me?: boolean;
777779
/** Status of the linked inbox report, for `open_linked_report`. */
778780
report_status?: string;
779781
}

packages/ui/src/features/scouts/components/ScoutsFleetSection.tsx

Lines changed: 73 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
computeFleetSummary,
1111
computeScoutRollups,
1212
getScoutOrigin,
13+
isScoutCreatedByUser,
1314
sortConfigsForDisplay,
1415
} from "@posthog/core/scouts/scoutPresentation";
1516
import {
@@ -27,10 +28,12 @@ import { RelativeTimestamp } from "@posthog/ui/primitives/RelativeTimestamp";
2728
import { track } from "@posthog/ui/shell/analytics";
2829
import { Box, Flex, Text } from "@radix-ui/themes";
2930
import { useEffect, useMemo, useRef, useState } from "react";
31+
import { useMeQuery } from "../../auth/useMeQuery";
3032
import { useScoutChatTask } from "../hooks/useScoutChatTask";
3133
import { useScoutConfigMutations } from "../hooks/useScoutConfigMutations";
3234
import { useScoutConfigs } from "../hooks/useScoutConfigs";
3335
import { useScoutRuns } from "../hooks/useScoutRuns";
36+
import { useScoutSkillCreators } from "../hooks/useScoutSkillCreators";
3437
import { FleetFindingsCallout } from "./FleetFindingsCallout";
3538
import { FleetMemoryCallout } from "./FleetMemoryCallout";
3639
import { ScoutAlphaBanner } from "./ScoutAlphaBanner";
@@ -154,6 +157,9 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
154157
const { data: runsWindow } = useScoutRuns();
155158
const { updateConfig } = useScoutConfigMutations();
156159
const [hideDisabled, setHideDisabled] = useState(false);
160+
const [createdByMe, setCreatedByMe] = useState(false);
161+
const { data: creators } = useScoutSkillCreators();
162+
const { data: currentUser } = useMeQuery();
157163
useTrackFleetViewed(configs);
158164

159165
const runs = runsWindow?.runs;
@@ -162,10 +168,28 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
162168
() => computeFleetSummary(configs, rollups),
163169
[configs, rollups],
164170
);
171+
// Null creators = the skills API is gated for this org, so authorship is
172+
// unknowable; skip the affordance instead of offering an always-empty filter.
173+
const canFilterByCreator = !!creators && !!currentUser;
165174
const visibleConfigs = useMemo(() => {
166-
const sorted = sortConfigsForDisplay(configs);
167-
return hideDisabled ? sorted.filter((config) => config.enabled) : sorted;
168-
}, [configs, hideDisabled]);
175+
let sorted = sortConfigsForDisplay(configs);
176+
if (hideDisabled) {
177+
sorted = sorted.filter((config) => config.enabled);
178+
}
179+
if (createdByMe && canFilterByCreator) {
180+
sorted = sorted.filter((config) =>
181+
isScoutCreatedByUser(creators?.get(config.skill_name), currentUser),
182+
);
183+
}
184+
return sorted;
185+
}, [
186+
configs,
187+
hideDisabled,
188+
createdByMe,
189+
canFilterByCreator,
190+
creators,
191+
currentUser,
192+
]);
169193

170194
return (
171195
<Flex direction="column" gap="3">
@@ -187,6 +211,36 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
187211
</span>
188212
</Text>
189213
<span className="flex-1" />
214+
{canFilterByCreator ? (
215+
<button
216+
type="button"
217+
aria-pressed={createdByMe}
218+
onClick={() => {
219+
const next = !createdByMe;
220+
setCreatedByMe(next);
221+
track(ANALYTICS_EVENTS.SCOUT_ACTION, {
222+
action_type: "toggle_created_by_me",
223+
surface: "fleet_list",
224+
created_by_me: next,
225+
filter_match_count: next
226+
? configs.filter((config) =>
227+
isScoutCreatedByUser(
228+
creators?.get(config.skill_name),
229+
currentUser,
230+
),
231+
).length
232+
: undefined,
233+
});
234+
}}
235+
className={`rounded px-1.5 py-0.5 text-[12px] transition-colors ${
236+
createdByMe
237+
? "bg-(--gray-4) text-gray-12"
238+
: "text-gray-10 hover:bg-gray-3 hover:text-gray-12"
239+
}`}
240+
>
241+
Created by me
242+
</button>
243+
) : null}
190244
<button
191245
type="button"
192246
onClick={() => {
@@ -238,14 +292,22 @@ function ScoutsFleetList({ configs }: { configs: ScoutConfig[] }) {
238292
{/* Bounded to roughly 10 rows; larger fleets scroll within the section. */}
239293
<div className="max-h-[710px] overflow-y-auto">
240294
<Flex direction="column" gap="2">
241-
{visibleConfigs.map((config) => (
242-
<ScoutRowCard
243-
key={config.id}
244-
config={config}
245-
rollup={rollups.get(config.skill_name)}
246-
onUpdate={updateConfig}
247-
/>
248-
))}
295+
{visibleConfigs.length === 0 ? (
296+
<Text className="px-1 py-2 text-[12.5px] text-gray-10">
297+
{createdByMe
298+
? "No scouts created by you match the current filters."
299+
: "No scouts match the current filters."}
300+
</Text>
301+
) : (
302+
visibleConfigs.map((config) => (
303+
<ScoutRowCard
304+
key={config.id}
305+
config={config}
306+
rollup={rollups.get(config.skill_name)}
307+
onUpdate={updateConfig}
308+
/>
309+
))
310+
)}
249311
</Flex>
250312
</div>
251313

packages/ui/src/features/scouts/hooks/scoutQueryKeys.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ export const scoutQueryKeys = {
44
metadata: (projectId: number | null) =>
55
["scouts", "metadata", projectId] as const,
66
runs: (projectId: number | null) => ["scouts", "runs", projectId] as const,
7+
skillCreators: (projectId: number | null) =>
8+
["scouts", "skillCreators", projectId] as const,
79
scratchpad: (projectId: number | null) =>
810
["scouts", "scratchpad", projectId] as const,
911
emissions: (projectId: number | null, runIds: string[]) =>
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
buildScoutCreatorIndex,
3+
type ScoutCreatorIndex,
4+
} from "@posthog/core/scouts/scoutPresentation";
5+
import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
6+
import { useAuthStateValue } from "../../auth/store";
7+
import { scoutQueryKeys } from "./scoutQueryKeys";
8+
9+
/**
10+
* Skill name → author of each scout's backing skill, for creator filtering.
11+
* Resolves to null when the org lacks the team-skills feature — callers should
12+
* drop creator affordances entirely rather than show an always-empty filter.
13+
*/
14+
export function useScoutSkillCreators() {
15+
const projectId = useAuthStateValue((state) => state.currentProjectId);
16+
return useAuthenticatedQuery<ScoutCreatorIndex | null>(
17+
scoutQueryKeys.skillCreators(projectId),
18+
async (client) => {
19+
const skills = await client.listLlmSkills({ category: "scout" });
20+
return skills === null ? null : buildScoutCreatorIndex(skills);
21+
},
22+
{ enabled: !!projectId, staleTime: 30_000 },
23+
);
24+
}

0 commit comments

Comments
 (0)