Skip to content

Commit 3d7fc62

Browse files
MattPuacharlesvien
andauthored
feat(loops): surface loop ownership and notifications (#3723)
Co-authored-by: Charles Vien <charles.v@posthog.com>
1 parent d714c54 commit 3d7fc62

9 files changed

Lines changed: 476 additions & 39 deletions

File tree

apps/code/snapshots.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -556,6 +556,14 @@ snapshots:
556556
hash: v1.k4693efd2.39d47f04f9ee6a6508342a8cddd00169b4b8c6628d1f63d81759e20f6dec136a.XJ6pqjbtYJv9ESWNflv286xYvR2Pn3xg44mLVyWTnv4
557557
git-dialogs--sync-success--light:
558558
hash: v1.k4693efd2.8d79d77c9338aeaa73734836f0e17fde987f6d3239914f4013889d3110e5088d.Rq-w2o1uhes_a5hUWqMXeiDkfnoPCZPt932LkEQXCco
559+
loops-loopslistview--comprehensive--dark:
560+
hash: v1.k4693efd2.a9b9a8b23cca2079c3ced1878b6537ee9fbbdd0d3de205a9584fbe7d6875d405.dWfER84S8r_MU7EVDmjW8CuCf6lNhTx6RjfV_cNzKOA
561+
loops-loopslistview--comprehensive--light:
562+
hash: v1.k4693efd2.9ff3f3787ef56ab9d1d39562eed6f19b6c9c1e7a6d79965fbf99fbac16ba33fb.DDuj_KZx0pcUxXwCgzNGFmCemCT1MuCzIQmbbPMgatw
563+
loops-loopslistview--long-mixed-list--dark:
564+
hash: v1.k4693efd2.683dedbfa7da3313eeaf52ff9e3cc7908af5c77173b1cad450b2acd61ac30cd4.YFm7fXVZSr29fa0R40pzq-FjRZrqIBEsIMiOiddB840
565+
loops-loopslistview--long-mixed-list--light:
566+
hash: v1.k4693efd2.577fa23e3bc43f0ba033a59669fa73035ff2d65df1f994cf5753ac306ca830cf.r3v1hne0uC4dHpkzXriz6yzcA3fD2b8wD6ZLxA_0Fuo
559567
scouts-scoutsfleetlist--filtered-to-you--dark:
560568
hash: v1.k4693efd2.a3af6e76ef36598eaa347bedc4430878ed62754660166398e0576a9978cd084b.x3Ky-O6HOw0srWdOmnnKJwFNpmGmQVWip0t7CwVaRXY
561569
scouts-scoutsfleetlist--filtered-to-you--light:

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2589,6 +2589,14 @@ export class PostHogAPIClient {
25892589
// Everyone in the current organization — the pool of taggable teammates for
25902590
// thread @-mentions. Membership churn is slow, so callers cache aggressively.
25912591
async listOrganizationMembers(): Promise<OrganizationMemberBasic[]> {
2592+
const result = await this.listOrganizationMembersWithStatus();
2593+
return result.members;
2594+
}
2595+
2596+
async listOrganizationMembersWithStatus(): Promise<{
2597+
members: OrganizationMemberBasic[];
2598+
isComplete: boolean;
2599+
}> {
25922600
const ORG_MEMBERS_MAX_PAGES = 20;
25932601
const ORG_MEMBERS_PAGE_SIZE = 200;
25942602
const all: OrganizationMemberBasic[] = [];
@@ -2609,15 +2617,15 @@ export class PostHogAPIClient {
26092617
next: string | null;
26102618
};
26112619
all.push(...page.results);
2612-
if (!page.next) return all;
2620+
if (!page.next) return { members: all, isComplete: true };
26132621
const nextUrl = new URL(page.next);
26142622
urlPath = `${nextUrl.pathname}${nextUrl.search}`;
26152623
}
26162624
log.warn(
26172625
`listOrganizationMembers hit MAX_PAGES (${ORG_MEMBERS_MAX_PAGES}); returning partial results`,
26182626
{ returned: all.length },
26192627
);
2620-
return all;
2628+
return { members: all, isComplete: false };
26212629
}
26222630

26232631
async sendRunCommand(
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,43 @@
11
import type { UserBasic } from "@posthog/shared/domain-types";
2+
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
23
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
34
import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery";
45
import { useMemo } from "react";
56

67
// Membership churn is slow; one fetch per session window is plenty.
78
const ORG_MEMBERS_STALE_MS = 5 * 60_000;
89

9-
export const ORG_MEMBERS_QUERY_KEY = ["org-members"] as const;
10+
export const orgMembersQueryKey = (orgId: string | null) =>
11+
["org-members", orgId] as const;
1012

1113
/** Members of the current organization, sorted by display name. */
1214
export function useOrgMembers(options?: { enabled?: boolean }): {
1315
members: UserBasic[];
1416
isLoading: boolean;
17+
isError: boolean;
18+
isComplete: boolean;
1519
} {
20+
const currentOrgId = useAuthStateValue((state) => state.currentOrgId);
1621
const query = useAuthenticatedQuery(
17-
ORG_MEMBERS_QUERY_KEY,
18-
(client) => client.listOrganizationMembers(),
22+
orgMembersQueryKey(currentOrgId),
23+
(client) => client.listOrganizationMembersWithStatus(),
1924
{
2025
enabled: options?.enabled ?? true,
2126
staleTime: ORG_MEMBERS_STALE_MS,
2227
},
2328
);
2429
const members = useMemo(
2530
() =>
26-
(query.data ?? [])
31+
(query.data?.members ?? [])
2732
.map((member) => member.user)
2833
.filter((user) => !!user?.email)
2934
.sort((a, b) => userDisplayName(a).localeCompare(userDisplayName(b))),
3035
[query.data],
3136
);
32-
return { members, isLoading: query.isLoading };
37+
return {
38+
members,
39+
isLoading: query.isLoading,
40+
isError: query.isError,
41+
isComplete: query.data?.isComplete ?? false,
42+
};
3343
}

packages/ui/src/features/loops/components/LoopDetailView.tsx

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ import {
1313
Switch,
1414
Textarea,
1515
} from "@posthog/quill";
16+
import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar";
17+
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
18+
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
1619
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
1720
import { toast } from "@posthog/ui/primitives/toast";
1821
import {
@@ -33,6 +36,7 @@ import {
3336
describeTrigger,
3437
loopStatusColor,
3538
loopStatusLabel,
39+
summarizeNotificationDestinations,
3640
} from "../loopDisplay";
3741
import { LoopLoadError } from "./LoopFallbacks";
3842
import { LoopRunRow } from "./LoopRunRow";
@@ -259,6 +263,33 @@ function loopStatusBadgeVariant(
259263

260264
function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
261265
const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model);
266+
const {
267+
members,
268+
isLoading: membersLoading,
269+
isError: membersError,
270+
isComplete: membersComplete,
271+
} = useOrgMembers({ enabled: loop.visibility === "team" });
272+
const creator = members.find((member) => member.id === loop.created_by_id);
273+
let creatorContent: React.ReactNode = null;
274+
if (loop.visibility === "team" && membersError) {
275+
creatorContent = "Creator unavailable";
276+
} else if (loop.visibility === "team" && membersLoading) {
277+
creatorContent = "Loading…";
278+
} else if (loop.visibility === "team" && creator) {
279+
creatorContent = (
280+
<Flex align="center" gap="2">
281+
<UserAvatar user={creator} size="xs" />
282+
{userDisplayName(creator)}
283+
</Flex>
284+
);
285+
} else if (loop.visibility === "team" && membersComplete) {
286+
creatorContent = "Former organization member";
287+
} else if (loop.visibility === "team") {
288+
creatorContent = "Creator unavailable";
289+
}
290+
const notificationDestinations = summarizeNotificationDestinations(
291+
loop.notifications,
292+
);
262293

263294
return (
264295
<Flex direction="column" gap="3">
@@ -287,6 +318,10 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
287318
: "None (connector-only loop)"}
288319
</SummaryRow>
289320

321+
{loop.visibility === "team" ? (
322+
<SummaryRow label="Created by">{creatorContent}</SummaryRow>
323+
) : null}
324+
290325
<SummaryRow label="Triggers">
291326
{loop.triggers.length === 0 ? (
292327
"No triggers configured"
@@ -301,6 +336,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
301336
</Flex>
302337
)}
303338
</SummaryRow>
339+
340+
{notificationDestinations.length > 0 ? (
341+
<SummaryRow label="Notifications">
342+
{notificationDestinations.join(", ")}
343+
</SummaryRow>
344+
) : null}
304345
</Flex>
305346
</Flex>
306347
);

packages/ui/src/features/loops/components/LoopRow.tsx

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,60 @@
11
import { CaretRightIcon, RepeatIcon } from "@phosphor-icons/react";
22
import type { LoopSchemas } from "@posthog/api-client/loops";
3+
import type { UserBasic } from "@posthog/shared/domain-types";
4+
import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay";
35
import { Badge } from "@posthog/ui/primitives/Badge";
46
import { Flex, Text } from "@radix-ui/themes";
57
import { Link } from "@tanstack/react-router";
6-
import { loopStatusColor, loopStatusLabel } from "../loopDisplay";
8+
import {
9+
loopStatusColor,
10+
loopStatusLabel,
11+
summarizeNotificationDestinations,
12+
} from "../loopDisplay";
13+
14+
export function LoopRow({
15+
loop,
16+
creator,
17+
creatorLoading = false,
18+
creatorError = false,
19+
creatorLookupComplete = true,
20+
}: {
21+
loop: LoopSchemas.Loop;
22+
creator?: UserBasic;
23+
creatorLoading?: boolean;
24+
creatorError?: boolean;
25+
creatorLookupComplete?: boolean;
26+
}) {
27+
const description = loop.description.trim();
28+
const triggerLabel = loop.triggers.length === 1 ? "trigger" : "triggers";
29+
const triggerSummary =
30+
loop.triggers.length === 0
31+
? "No triggers configured"
32+
: `${loop.triggers.length} ${triggerLabel}`;
33+
34+
let creatorLabel: string | null = null;
35+
if (loop.visibility === "team" && creatorError) {
36+
creatorLabel = "Creator unavailable";
37+
} else if (
38+
loop.visibility === "team" &&
39+
!creatorLoading &&
40+
!creatorLookupComplete
41+
) {
42+
creatorLabel = "Creator unavailable";
43+
} else if (loop.visibility === "team" && !creatorLoading) {
44+
creatorLabel = creator
45+
? `Created by ${userDisplayName(creator)}`
46+
: "Created by a former organization member";
47+
}
48+
49+
const metadata = [triggerSummary];
50+
const notificationDestinations = summarizeNotificationDestinations(
51+
loop.notifications,
52+
);
53+
if (notificationDestinations.length > 0) {
54+
metadata.push(`Notifications: ${notificationDestinations.join(", ")}`);
55+
}
56+
if (creatorLabel) metadata.push(creatorLabel);
757

8-
export function LoopRow({ loop }: { loop: LoopSchemas.Loop }) {
958
return (
1059
<Link
1160
to="/code/loops/$loopId"
@@ -22,13 +71,14 @@ export function LoopRow({ loop }: { loop: LoopSchemas.Loop }) {
2271
<Badge color={loopStatusColor(loop)}>{loopStatusLabel(loop)}</Badge>
2372
<Badge color="gray">{loop.visibility}</Badge>
2473
</Flex>
25-
<Text className="truncate text-[12px] text-gray-11 leading-snug">
26-
{loop.description.trim()
27-
? loop.description
28-
: loop.triggers.length === 0
29-
? "No triggers configured"
30-
: `${loop.triggers.length} trigger${loop.triggers.length === 1 ? "" : "s"}`}
74+
<Text className="text-[12px] text-gray-11 leading-snug">
75+
{metadata.join(" · ")}
3176
</Text>
77+
{description ? (
78+
<Text className="truncate text-[12px] text-gray-10 leading-snug">
79+
{description}
80+
</Text>
81+
) : null}
3282
</Flex>
3383
</Flex>
3484
<Flex align="center" gap="3" className="shrink-0">

0 commit comments

Comments
 (0)