From c8af83f20e39f4e89dd4ea4bdce80b4c2406ccbd Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:16:12 -0400 Subject: [PATCH 1/9] feat(loops): surface loop ownership and notifications Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../features/canvas/hooks/useOrgMembers.ts | 3 +- .../loops/components/LoopDetailView.tsx | 36 ++++++++++ .../src/features/loops/components/LoopRow.tsx | 58 ++++++++++++++-- .../loops/components/LoopsListView.tsx | 66 ++++++++++++++++--- .../ui/src/features/loops/loopDisplay.test.ts | 31 ++++++++- packages/ui/src/features/loops/loopDisplay.ts | 19 ++++++ 6 files changed, 195 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/canvas/hooks/useOrgMembers.ts b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts index 334692f544..70ab33ab7d 100644 --- a/packages/ui/src/features/canvas/hooks/useOrgMembers.ts +++ b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts @@ -12,6 +12,7 @@ export const ORG_MEMBERS_QUERY_KEY = ["org-members"] as const; export function useOrgMembers(options?: { enabled?: boolean }): { members: UserBasic[]; isLoading: boolean; + isError: boolean; } { const query = useAuthenticatedQuery( ORG_MEMBERS_QUERY_KEY, @@ -29,5 +30,5 @@ export function useOrgMembers(options?: { enabled?: boolean }): { .sort((a, b) => userDisplayName(a).localeCompare(userDisplayName(b))), [query.data], ); - return { members, isLoading: query.isLoading }; + return { members, isLoading: query.isLoading, isError: query.isError }; } diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 4c24e18f27..0198405f03 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -13,6 +13,9 @@ import { Switch, Textarea, } from "@posthog/quill"; +import { UserAvatar } from "@posthog/ui/features/auth/UserAvatar"; +import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { toast } from "@posthog/ui/primitives/toast"; import { @@ -33,6 +36,7 @@ import { describeTrigger, loopStatusColor, loopStatusLabel, + summarizeNotificationDestinations, } from "../loopDisplay"; import { LoopLoadError } from "./LoopFallbacks"; import { LoopRunRow } from "./LoopRunRow"; @@ -259,6 +263,30 @@ function loopStatusBadgeVariant( function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + const { + members, + isLoading: membersLoading, + isError: membersError, + } = useOrgMembers({ enabled: loop.visibility === "team" }); + const creator = members.find((member) => member.id === loop.created_by_id); + let creatorContent: React.ReactNode = "You"; + if (loop.visibility === "team" && membersError) { + creatorContent = "Creator unavailable"; + } else if (loop.visibility === "team" && membersLoading) { + creatorContent = "Loading…"; + } else if (loop.visibility === "team" && creator) { + creatorContent = ( + + + {userDisplayName(creator)} + + ); + } else if (loop.visibility === "team") { + creatorContent = "Former organization member"; + } + const notificationDestinations = summarizeNotificationDestinations( + loop.notifications, + ); return ( @@ -287,6 +315,8 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { : "None (connector-only loop)"} + {creatorContent} + {loop.triggers.length === 0 ? ( "No triggers configured" @@ -301,6 +331,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { )} + + {notificationDestinations.length > 0 ? ( + + {notificationDestinations.join(", ")} + + ) : null} ); diff --git a/packages/ui/src/features/loops/components/LoopRow.tsx b/packages/ui/src/features/loops/components/LoopRow.tsx index 23e38bd27e..748ccdcb45 100644 --- a/packages/ui/src/features/loops/components/LoopRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRow.tsx @@ -1,11 +1,54 @@ import { CaretRightIcon, RepeatIcon } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { Badge } from "@posthog/ui/primitives/Badge"; import { Flex, Text } from "@radix-ui/themes"; import { Link } from "@tanstack/react-router"; -import { loopStatusColor, loopStatusLabel } from "../loopDisplay"; +import { + loopStatusColor, + loopStatusLabel, + summarizeNotificationDestinations, +} from "../loopDisplay"; + +export function LoopRow({ + loop, + creator, + creatorLoading = false, + creatorError = false, +}: { + loop: LoopSchemas.Loop; + creator?: UserBasic; + creatorLoading?: boolean; + creatorError?: boolean; +}) { + const description = loop.description.trim(); + const triggerLabel = loop.triggers.length === 1 ? "trigger" : "triggers"; + const triggerSummary = + loop.triggers.length === 0 + ? "No triggers configured" + : `${loop.triggers.length} ${triggerLabel}`; + + let creatorLabel: string | null = null; + if (loop.visibility === "personal") { + creatorLabel = "Created by you"; + } else if (creatorError) { + creatorLabel = "Creator unavailable"; + } else if (!creatorLoading) { + creatorLabel = creator + ? `Created by ${userDisplayName(creator)}` + : "Created by a former organization member"; + } + + const metadata = [triggerSummary]; + const notificationDestinations = summarizeNotificationDestinations( + loop.notifications, + ); + if (notificationDestinations.length > 0) { + metadata.push(`Notifications: ${notificationDestinations.join(", ")}`); + } + if (creatorLabel) metadata.push(creatorLabel); -export function LoopRow({ loop }: { loop: LoopSchemas.Loop }) { return ( {loop.visibility} - {loop.description.trim() - ? loop.description - : loop.triggers.length === 0 - ? "No triggers configured" - : `${loop.triggers.length} trigger${loop.triggers.length === 1 ? "" : "s"}`} + {metadata.join(" · ")} + {description ? ( + + {description} + + ) : null} diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 8e6021831a..5032861fba 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,4 +1,6 @@ import { CloudIcon, PlusIcon, RepeatIcon } from "@phosphor-icons/react"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; import { Button } from "@posthog/ui/primitives/Button"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; @@ -42,6 +44,15 @@ export function LoopsListView() { useSetHeaderContent(headerContent); const allLoops = loops ?? []; + const personalLoops = allLoops.filter( + (loop) => loop.visibility === "personal", + ); + const teamLoops = allLoops.filter((loop) => loop.visibility === "team"); + const { + members, + isLoading: membersLoading, + isError: membersError, + } = useOrgMembers({ enabled: teamLoops.length > 0 }); const startBlank = () => { useLoopDraftStore.getState().setPrefill(null); @@ -112,15 +123,19 @@ export function LoopsListView() { } /> ) : allLoops.length > 0 ? ( - - - Your loops - - - {allLoops.map((loop) => ( - - ))} - + + {personalLoops.length > 0 ? ( + + ) : null} + {teamLoops.length > 0 ? ( + + ) : null} ) : ( @@ -142,3 +157,36 @@ export function LoopsListView() { ); } + +function LoopListSection({ + title, + loops, + members = [], + membersLoading = false, + membersError = false, +}: { + title: string; + loops: NonNullable["data"]>; + members?: UserBasic[]; + membersLoading?: boolean; + membersError?: boolean; +}) { + return ( + + + {title} + + + {loops.map((loop) => ( + member.id === loop.created_by_id)} + creatorLoading={membersLoading} + creatorError={membersError} + /> + ))} + + + ); +} diff --git a/packages/ui/src/features/loops/loopDisplay.test.ts b/packages/ui/src/features/loops/loopDisplay.test.ts index 03e585b7a7..bbb71ae7dc 100644 --- a/packages/ui/src/features/loops/loopDisplay.test.ts +++ b/packages/ui/src/features/loops/loopDisplay.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { describeTrigger } from "./loopDisplay"; +import { + describeTrigger, + summarizeNotificationDestinations, +} from "./loopDisplay"; describe("describeTrigger", () => { it.each([ @@ -25,3 +28,29 @@ describe("describeTrigger", () => { ).toBe("Schedule · */15 * * * * (UTC)"); }); }); + +describe("summarizeNotificationDestinations", () => { + it("lists enabled destinations and includes the Slack channel", () => { + expect( + summarizeNotificationDestinations({ + push: { enabled: true, events: [], params: {} }, + email: { enabled: false, events: [], params: {} }, + slack: { + enabled: true, + events: [], + params: { channel_name: "#loops" }, + }, + }), + ).toEqual(["Push", "Slack · #loops"]); + }); + + it("omits disabled destinations", () => { + expect( + summarizeNotificationDestinations({ + push: { enabled: false, events: [], params: {} }, + email: { enabled: false, events: [], params: {} }, + slack: { enabled: false, events: [], params: {} }, + }), + ).toEqual([]); + }); +}); diff --git a/packages/ui/src/features/loops/loopDisplay.ts b/packages/ui/src/features/loops/loopDisplay.ts index beacd6958f..b77621fda6 100644 --- a/packages/ui/src/features/loops/loopDisplay.ts +++ b/packages/ui/src/features/loops/loopDisplay.ts @@ -47,6 +47,25 @@ interface TriggerLike { config: LoopSchemas.LoopTriggerConfig; } +export function summarizeNotificationDestinations( + notifications: LoopSchemas.LoopNotifications, +): string[] { + const destinations: string[] = []; + + if (notifications.push.enabled) destinations.push("Push"); + if (notifications.email.enabled) destinations.push("Email"); + if (notifications.slack.enabled) { + const channelName = notifications.slack.params.channel_name; + destinations.push( + typeof channelName === "string" && channelName.length > 0 + ? `Slack · #${channelName.replace(/^#/, "")}` + : "Slack", + ); + } + + return destinations; +} + /** Compact one-word-ish label for the form's review list. */ export function summarizeTrigger(trigger: TriggerLike): string { if (trigger.type === "schedule") { From 16edd2411ed16e1a0101450dcd653a2afde6623e Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:23:58 -0400 Subject: [PATCH 2/9] test(loops): add list view stories Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../components/LoopsListView.stories.tsx | 213 ++++++++++++++++++ .../loops/components/LoopsListView.tsx | 53 ++++- 2 files changed, 259 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/loops/components/LoopsListView.stories.tsx diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx new file mode 100644 index 0000000000..cf22a4a6ac --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -0,0 +1,213 @@ +import type { LoopSchemas } from "@posthog/api-client/loops"; +import type { UserBasic } from "@posthog/shared/domain-types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { LoopsListViewPresentation } from "./LoopsListView"; + +const VINCENT: UserBasic = { + id: 2, + uuid: "user-vincent", + email: "vincent@example.com", + first_name: "Vincent", + last_name: "Ge", +}; + +const PAUL: UserBasic = { + id: 3, + uuid: "user-paul", + email: "paul@example.com", + first_name: "Paul", + last_name: "Bean", +}; + +function notifications( + enabled: Array, + slackChannel = "loops-alerts", +): LoopSchemas.LoopNotifications { + const events: LoopSchemas.LoopNotificationEventEnum[] = [ + "run_completed", + "run_failed", + ]; + return { + push: { enabled: enabled.includes("push"), events, params: {} }, + email: { enabled: enabled.includes("email"), events, params: {} }, + slack: { + enabled: enabled.includes("slack"), + events, + params: { channel_id: "C012345", channel_name: slackChannel }, + }, + }; +} + +function loop( + id: string, + overrides: Partial = {}, +): LoopSchemas.Loop { + const visibility = overrides.visibility ?? "personal"; + const createdById = visibility === "personal" ? 1 : VINCENT.id; + return { + id, + team_id: 2, + created_by_id: createdById, + name: `Loop ${id}`, + description: "", + visibility, + instructions: "Review recent activity and report anything notable.", + runtime_adapter: "claude", + model: "claude-sonnet-4-5", + reasoning_effort: null, + repositories: [], + sandbox_environment_id: null, + enabled: true, + disabled_reason: null, + overlap_policy: "skip", + behaviors: { + create_prs: false, + watch_ci: false, + fix_review_comments: false, + max_fix_iterations: 3, + }, + connectors: { mcp_installation_ids: [], posthog_mcp_scopes: "read_only" }, + notifications: notifications([]), + context_target: null, + internal: false, + origin_product: "user_created", + last_run_at: null, + last_run_status: null, + last_error: null, + consecutive_failures: 0, + created_at: "2026-07-20T12:00:00Z", + updated_at: "2026-07-20T12:00:00Z", + triggers: [ + { + id: `trigger-${id}`, + loop_id: id, + type: "schedule", + enabled: true, + config: { cron_expression: "0 9 * * 1-5", timezone: "UTC" }, + schedule_sync_status: "synced", + last_fired_at: null, + created_at: "2026-07-20T12:00:00Z", + updated_at: "2026-07-20T12:00:00Z", + }, + ], + ...overrides, + }; +} + +const MIXED_LOOPS: LoopSchemas.Loop[] = [ + loop("personal-push", { + name: "Daily product pulse", + notifications: notifications(["push"]), + }), + loop("personal-email", { + name: "Weekly account summary", + notifications: notifications(["email"]), + }), + loop("team-slack", { + name: "Agentic-detection rollout monitoring", + visibility: "team", + notifications: notifications(["slack"], "agentic-rollout"), + }), + loop("team-all", { + name: "Production incident watch", + visibility: "team", + created_by_id: PAUL.id, + notifications: notifications(["push", "email", "slack"], "incidents"), + }), +]; + +const meta: Meta = { + title: "Loops/LoopsListView", + component: LoopsListViewPresentation, + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + loops: MIXED_LOOPS, + members: [VINCENT, PAUL], + onStartBlank: () => {}, + onStartFromTemplate: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const MixedNotifications: Story = {}; + +export const LongContent: Story = { + args: { + loops: [ + loop("long-personal", { + name: "A very long personal loop name that tests truncation without displacing status badges or navigation", + description: + "This description is intentionally long so notification and creator metadata remain visible on their own line while the descriptive copy truncates independently.", + notifications: notifications(["push", "email"]), + }), + loop("long-team", { + name: "Monitor every production deployment and correlate regressions across all customer-facing services", + description: + "Checks deployments, error rates, session replay signals, support volume, and conversion changes before posting a concise summary for the team.", + visibility: "team", + notifications: notifications(["push", "email", "slack"]), + }), + ], + }, +}; + +export const LongMixedList: Story = { + args: { + loops: Array.from({ length: 18 }, (_, index) => { + const visibility = index % 3 === 0 ? "team" : "personal"; + const channels: Array = []; + if (index % 2 === 0) channels.push("push"); + if (index % 4 === 0) channels.push("email"); + if (index % 3 === 0) channels.push("slack"); + return loop(`long-list-${index + 1}`, { + name: `Loop ${String(index + 1).padStart(2, "0")} · ${index % 2 === 0 ? "Monitor product health" : "Summarize customer feedback"}`, + visibility, + created_by_id: visibility === "team" ? VINCENT.id : 1, + enabled: index % 7 !== 0, + notifications: notifications(channels, `team-loop-${index + 1}`), + }); + }), + }, +}; + +export const CreatorStates: Story = { + args: { + loops: [ + loop("personal-owner", { name: "Personal loop" }), + loop("known-owner", { + name: "Known team creator", + visibility: "team", + }), + loop("former-owner", { + name: "Former organization member", + visibility: "team", + created_by_id: 999, + }), + ], + }, +}; + +export const CreatorLoading: Story = { + args: { members: [], membersLoading: true }, +}; + +export const CreatorUnavailable: Story = { + args: { members: [], membersError: true }, +}; + +export const Empty: Story = { args: { loops: [] } }; + +export const Loading: Story = { args: { loops: [], isLoading: true } }; + +export const LoadError: Story = { + args: { loops: [], error: new Error("Loops could not be loaded") }, +}; diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 5032861fba..bb7a54e9c1 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,4 +1,5 @@ import { CloudIcon, PlusIcon, RepeatIcon } from "@phosphor-icons/react"; +import type { LoopSchemas } from "@posthog/api-client/loops"; import type { UserBasic } from "@posthog/shared/domain-types"; import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers"; import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent"; @@ -44,9 +45,6 @@ export function LoopsListView() { useSetHeaderContent(headerContent); const allLoops = loops ?? []; - const personalLoops = allLoops.filter( - (loop) => loop.visibility === "personal", - ); const teamLoops = allLoops.filter((loop) => loop.visibility === "team"); const { members, @@ -66,6 +64,47 @@ export function LoopsListView() { navigateToNewLoop(); }; + return ( + + ); +} + +interface LoopsListViewPresentationProps { + loops: LoopSchemas.Loop[]; + isLoading?: boolean; + error?: unknown; + limitReason?: string | null; + members?: UserBasic[]; + membersLoading?: boolean; + membersError?: boolean; + onStartBlank: () => void; + onStartFromTemplate: (template: LoopTemplate) => void; +} + +export function LoopsListViewPresentation({ + loops, + isLoading = false, + error = null, + limitReason = null, + members = [], + membersLoading = false, + membersError = false, + onStartBlank, + onStartFromTemplate, +}: LoopsListViewPresentationProps) { + const personalLoops = loops.filter((loop) => loop.visibility === "personal"); + const teamLoops = loops.filter((loop) => loop.visibility === "team"); + return (
@@ -102,7 +141,7 @@ export function LoopsListView() { variant="soft" color="gray" size="2" - onClick={startBlank} + onClick={onStartBlank} disabled={limitReason != null} disabledReason={limitReason} > @@ -113,7 +152,7 @@ export function LoopsListView() { {isLoading ? ( - ) : isError ? ( + ) : error ? ( - ) : allLoops.length > 0 ? ( + ) : loops.length > 0 ? ( {personalLoops.length > 0 ? ( @@ -141,7 +180,7 @@ export function LoopsListView() { )} - +
From 29fdbc9a524e860d6435eda20f559e60efe86892 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:28:05 -0400 Subject: [PATCH 3/9] test(loops): consolidate list stories Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../components/LoopsListView.stories.tsx | 76 +++++-------------- 1 file changed, 19 insertions(+), 57 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index cf22a4a6ac..4a099a40f8 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -99,9 +99,11 @@ const MIXED_LOOPS: LoopSchemas.Loop[] = [ name: "Daily product pulse", notifications: notifications(["push"]), }), - loop("personal-email", { - name: "Weekly account summary", - notifications: notifications(["email"]), + loop("personal-long", { + name: "A very long personal loop name that tests truncation without displacing status badges or navigation", + description: + "This intentionally long description verifies that creator and notification metadata remain visible while descriptive copy truncates independently.", + notifications: notifications(["push", "email"]), }), loop("team-slack", { name: "Agentic-detection rollout monitoring", @@ -114,6 +116,19 @@ const MIXED_LOOPS: LoopSchemas.Loop[] = [ created_by_id: PAUL.id, notifications: notifications(["push", "email", "slack"], "incidents"), }), + loop("team-none", { + name: "Paused loop without notifications", + visibility: "team", + enabled: false, + }), + loop("team-former-owner", { + name: "Loop owned by a former organization member", + visibility: "team", + created_by_id: 999, + last_run_status: "failed", + consecutive_failures: 3, + notifications: notifications(["email"]), + }), ]; const meta: Meta = { @@ -138,27 +153,7 @@ const meta: Meta = { export default meta; type Story = StoryObj; -export const MixedNotifications: Story = {}; - -export const LongContent: Story = { - args: { - loops: [ - loop("long-personal", { - name: "A very long personal loop name that tests truncation without displacing status badges or navigation", - description: - "This description is intentionally long so notification and creator metadata remain visible on their own line while the descriptive copy truncates independently.", - notifications: notifications(["push", "email"]), - }), - loop("long-team", { - name: "Monitor every production deployment and correlate regressions across all customer-facing services", - description: - "Checks deployments, error rates, session replay signals, support volume, and conversion changes before posting a concise summary for the team.", - visibility: "team", - notifications: notifications(["push", "email", "slack"]), - }), - ], - }, -}; +export const Comprehensive: Story = {}; export const LongMixedList: Story = { args: { @@ -178,36 +173,3 @@ export const LongMixedList: Story = { }), }, }; - -export const CreatorStates: Story = { - args: { - loops: [ - loop("personal-owner", { name: "Personal loop" }), - loop("known-owner", { - name: "Known team creator", - visibility: "team", - }), - loop("former-owner", { - name: "Former organization member", - visibility: "team", - created_by_id: 999, - }), - ], - }, -}; - -export const CreatorLoading: Story = { - args: { members: [], membersLoading: true }, -}; - -export const CreatorUnavailable: Story = { - args: { members: [], membersError: true }, -}; - -export const Empty: Story = { args: { loops: [] } }; - -export const Loading: Story = { args: { loops: [], isLoading: true } }; - -export const LoadError: Story = { - args: { loops: [], error: new Error("Loops could not be loaded") }, -}; From 1afd76e637c0617cb255546593535627851f4913 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:31:02 -0400 Subject: [PATCH 4/9] fix(loops): omit redundant personal ownership Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../ui/src/features/loops/components/LoopDetailView.tsx | 6 ++++-- packages/ui/src/features/loops/components/LoopRow.tsx | 6 ++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index 0198405f03..a5167adc30 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -269,7 +269,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { isError: membersError, } = useOrgMembers({ enabled: loop.visibility === "team" }); const creator = members.find((member) => member.id === loop.created_by_id); - let creatorContent: React.ReactNode = "You"; + let creatorContent: React.ReactNode = null; if (loop.visibility === "team" && membersError) { creatorContent = "Creator unavailable"; } else if (loop.visibility === "team" && membersLoading) { @@ -315,7 +315,9 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { : "None (connector-only loop)"} - {creatorContent} + {loop.visibility === "team" ? ( + {creatorContent} + ) : null} {loop.triggers.length === 0 ? ( diff --git a/packages/ui/src/features/loops/components/LoopRow.tsx b/packages/ui/src/features/loops/components/LoopRow.tsx index 748ccdcb45..e6f02ebd1a 100644 --- a/packages/ui/src/features/loops/components/LoopRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRow.tsx @@ -30,11 +30,9 @@ export function LoopRow({ : `${loop.triggers.length} ${triggerLabel}`; let creatorLabel: string | null = null; - if (loop.visibility === "personal") { - creatorLabel = "Created by you"; - } else if (creatorError) { + if (loop.visibility === "team" && creatorError) { creatorLabel = "Creator unavailable"; - } else if (!creatorLoading) { + } else if (loop.visibility === "team" && !creatorLoading) { creatorLabel = creator ? `Created by ${userDisplayName(creator)}` : "Created by a former organization member"; From 5b0cbb285fe7bbc4864253b057f9255c96cc58f6 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:31:28 -0400 Subject: [PATCH 5/9] test(loops): use generic story creator Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../loops/components/LoopsListView.stories.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index 4a099a40f8..b39719fdcd 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -3,12 +3,12 @@ import type { UserBasic } from "@posthog/shared/domain-types"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { LoopsListViewPresentation } from "./LoopsListView"; -const VINCENT: UserBasic = { +const BOB: UserBasic = { id: 2, - uuid: "user-vincent", - email: "vincent@example.com", - first_name: "Vincent", - last_name: "Ge", + uuid: "user-bob", + email: "bob@example.com", + first_name: "Bob", + last_name: "John", }; const PAUL: UserBasic = { @@ -43,7 +43,7 @@ function loop( overrides: Partial = {}, ): LoopSchemas.Loop { const visibility = overrides.visibility ?? "personal"; - const createdById = visibility === "personal" ? 1 : VINCENT.id; + const createdById = visibility === "personal" ? 1 : BOB.id; return { id, team_id: 2, @@ -144,7 +144,7 @@ const meta: Meta = { ], args: { loops: MIXED_LOOPS, - members: [VINCENT, PAUL], + members: [BOB, PAUL], onStartBlank: () => {}, onStartFromTemplate: () => {}, }, @@ -166,7 +166,7 @@ export const LongMixedList: Story = { return loop(`long-list-${index + 1}`, { name: `Loop ${String(index + 1).padStart(2, "0")} · ${index % 2 === 0 ? "Monitor product health" : "Summarize customer feedback"}`, visibility, - created_by_id: visibility === "team" ? VINCENT.id : 1, + created_by_id: visibility === "team" ? BOB.id : 1, enabled: index % 7 !== 0, notifications: notifications(channels, `team-loop-${index + 1}`), }); From 22c78aef2d1ba3253808ee225e612b34797013f4 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:31:45 -0400 Subject: [PATCH 6/9] test(loops): use PostHog Hog story creator Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../loops/components/LoopsListView.stories.tsx | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index b39719fdcd..77f6c4a872 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -3,12 +3,12 @@ import type { UserBasic } from "@posthog/shared/domain-types"; import type { Meta, StoryObj } from "@storybook/react-vite"; import { LoopsListViewPresentation } from "./LoopsListView"; -const BOB: UserBasic = { +const POSTHOG_HOG: UserBasic = { id: 2, - uuid: "user-bob", - email: "bob@example.com", - first_name: "Bob", - last_name: "John", + uuid: "user-posthog-hog", + email: "hog@example.com", + first_name: "PostHog", + last_name: "Hog", }; const PAUL: UserBasic = { @@ -43,7 +43,7 @@ function loop( overrides: Partial = {}, ): LoopSchemas.Loop { const visibility = overrides.visibility ?? "personal"; - const createdById = visibility === "personal" ? 1 : BOB.id; + const createdById = visibility === "personal" ? 1 : POSTHOG_HOG.id; return { id, team_id: 2, @@ -144,7 +144,7 @@ const meta: Meta = { ], args: { loops: MIXED_LOOPS, - members: [BOB, PAUL], + members: [POSTHOG_HOG, PAUL], onStartBlank: () => {}, onStartFromTemplate: () => {}, }, @@ -166,7 +166,7 @@ export const LongMixedList: Story = { return loop(`long-list-${index + 1}`, { name: `Loop ${String(index + 1).padStart(2, "0")} · ${index % 2 === 0 ? "Monitor product health" : "Summarize customer feedback"}`, visibility, - created_by_id: visibility === "team" ? BOB.id : 1, + created_by_id: visibility === "team" ? POSTHOG_HOG.id : 1, enabled: index % 7 !== 0, notifications: notifications(channels, `team-loop-${index + 1}`), }); From f1bd2f266c61ea197f3e274ecc382be7ad235eb7 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 13:37:46 -0400 Subject: [PATCH 7/9] fix(loops): keep list metadata visible Generated-By: PostHog Code Task-Id: 5e66a9a6-6aca-44f1-8f6a-ba8a7c1dbd7b --- .../src/features/loops/components/LoopRow.tsx | 2 +- .../loops/components/LoopsListView.tsx | 36 ++++++++++--------- 2 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopRow.tsx b/packages/ui/src/features/loops/components/LoopRow.tsx index e6f02ebd1a..24e30d54d8 100644 --- a/packages/ui/src/features/loops/components/LoopRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRow.tsx @@ -63,7 +63,7 @@ export function LoopRow({ {loopStatusLabel(loop)} {loop.visibility}
- + {metadata.join(" · ")} {description ? ( diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index bb7a54e9c1..d8bb2fc2e1 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -22,6 +22,20 @@ function loopLimitReason(max: number): string { return `You've reached the limit of ${max} loops for this project. Delete one to add another.`; } +const EMPTY_MEMBERS: UserBasic[] = []; + +function startBlankLoop(): void { + useLoopDraftStore.getState().setPrefill(null); + navigateToNewLoop(); +} + +function startLoopFromTemplate(template: LoopTemplate): void { + useLoopDraftStore + .getState() + .setPrefill({ description: template.description, ...template.build() }); + navigateToNewLoop(); +} + export function LoopsListView() { const { data: loops, isLoading, isError, error } = useLoops(); const limits = useLoopLimits(); @@ -52,18 +66,6 @@ export function LoopsListView() { isError: membersError, } = useOrgMembers({ enabled: teamLoops.length > 0 }); - const startBlank = () => { - useLoopDraftStore.getState().setPrefill(null); - navigateToNewLoop(); - }; - - const startFromTemplate = (template: LoopTemplate) => { - useLoopDraftStore - .getState() - .setPrefill({ description: template.description, ...template.build() }); - navigateToNewLoop(); - }; - return ( ); } @@ -96,7 +98,7 @@ export function LoopsListViewPresentation({ isLoading = false, error = null, limitReason = null, - members = [], + members = EMPTY_MEMBERS, membersLoading = false, membersError = false, onStartBlank, @@ -200,12 +202,12 @@ export function LoopsListViewPresentation({ function LoopListSection({ title, loops, - members = [], + members = EMPTY_MEMBERS, membersLoading = false, membersError = false, }: { title: string; - loops: NonNullable["data"]>; + loops: LoopSchemas.Loop[]; members?: UserBasic[]; membersLoading?: boolean; membersError?: boolean; From db21a4047037b3a57e24c74afd88c24078a94078 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 18:20:38 +0000 Subject: [PATCH 8/9] chore(visual): update storybook baselines 4 updated Run: 96ef43a9-bfac-48d5-a987-da09c8ad27f1 Co-authored-by: MattPua <3376526+MattPua@users.noreply.github.com> --- apps/code/snapshots.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index ddc9527bed..47392c6380 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -556,6 +556,14 @@ snapshots: hash: v1.k4693efd2.39d47f04f9ee6a6508342a8cddd00169b4b8c6628d1f63d81759e20f6dec136a.XJ6pqjbtYJv9ESWNflv286xYvR2Pn3xg44mLVyWTnv4 git-dialogs--sync-success--light: hash: v1.k4693efd2.8d79d77c9338aeaa73734836f0e17fde987f6d3239914f4013889d3110e5088d.Rq-w2o1uhes_a5hUWqMXeiDkfnoPCZPt932LkEQXCco + loops-loopslistview--comprehensive--dark: + hash: v1.k4693efd2.a9b9a8b23cca2079c3ced1878b6537ee9fbbdd0d3de205a9584fbe7d6875d405.dWfER84S8r_MU7EVDmjW8CuCf6lNhTx6RjfV_cNzKOA + loops-loopslistview--comprehensive--light: + hash: v1.k4693efd2.9ff3f3787ef56ab9d1d39562eed6f19b6c9c1e7a6d79965fbf99fbac16ba33fb.DDuj_KZx0pcUxXwCgzNGFmCemCT1MuCzIQmbbPMgatw + loops-loopslistview--long-mixed-list--dark: + hash: v1.k4693efd2.683dedbfa7da3313eeaf52ff9e3cc7908af5c77173b1cad450b2acd61ac30cd4.YFm7fXVZSr29fa0R40pzq-FjRZrqIBEsIMiOiddB840 + loops-loopslistview--long-mixed-list--light: + hash: v1.k4693efd2.577fa23e3bc43f0ba033a59669fa73035ff2d65df1f994cf5753ac306ca830cf.r3v1hne0uC4dHpkzXriz6yzcA3fD2b8wD6ZLxA_0Fuo scouts-scoutsfleetlist--filtered-to-you--dark: hash: v1.k4693efd2.a3af6e76ef36598eaa347bedc4430878ed62754660166398e0576a9978cd084b.x3Ky-O6HOw0srWdOmnnKJwFNpmGmQVWip0t7CwVaRXY scouts-scoutsfleetlist--filtered-to-you--light: From f1b2b5e2705a6f799e4d779806cf9b51e1c79b47 Mon Sep 17 00:00:00 2001 From: Matt Pua Date: Wed, 22 Jul 2026 14:57:35 -0400 Subject: [PATCH 9/9] fix(loops): scope creator member lookups Generated-By: PostHog Code Task-Id: 9edb68e1-a82b-48f8-9d42-f3c635939c09 --- packages/api-client/src/posthog-client.ts | 12 ++++++++++-- .../features/canvas/hooks/useOrgMembers.ts | 19 ++++++++++++++----- .../loops/components/LoopDetailView.tsx | 5 ++++- .../src/features/loops/components/LoopRow.tsx | 8 ++++++++ .../loops/components/LoopsListView.tsx | 8 ++++++++ 5 files changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index 9d1907d4ec..565dfbf0dd 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -2589,6 +2589,14 @@ export class PostHogAPIClient { // Everyone in the current organization — the pool of taggable teammates for // thread @-mentions. Membership churn is slow, so callers cache aggressively. async listOrganizationMembers(): Promise { + const result = await this.listOrganizationMembersWithStatus(); + return result.members; + } + + async listOrganizationMembersWithStatus(): Promise<{ + members: OrganizationMemberBasic[]; + isComplete: boolean; + }> { const ORG_MEMBERS_MAX_PAGES = 20; const ORG_MEMBERS_PAGE_SIZE = 200; const all: OrganizationMemberBasic[] = []; @@ -2609,7 +2617,7 @@ export class PostHogAPIClient { next: string | null; }; all.push(...page.results); - if (!page.next) return all; + if (!page.next) return { members: all, isComplete: true }; const nextUrl = new URL(page.next); urlPath = `${nextUrl.pathname}${nextUrl.search}`; } @@ -2617,7 +2625,7 @@ export class PostHogAPIClient { `listOrganizationMembers hit MAX_PAGES (${ORG_MEMBERS_MAX_PAGES}); returning partial results`, { returned: all.length }, ); - return all; + return { members: all, isComplete: false }; } async sendRunCommand( diff --git a/packages/ui/src/features/canvas/hooks/useOrgMembers.ts b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts index 70ab33ab7d..360a06b62a 100644 --- a/packages/ui/src/features/canvas/hooks/useOrgMembers.ts +++ b/packages/ui/src/features/canvas/hooks/useOrgMembers.ts @@ -1,4 +1,5 @@ import type { UserBasic } from "@posthog/shared/domain-types"; +import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay"; import { useAuthenticatedQuery } from "@posthog/ui/hooks/useAuthenticatedQuery"; import { useMemo } from "react"; @@ -6,17 +7,20 @@ import { useMemo } from "react"; // Membership churn is slow; one fetch per session window is plenty. const ORG_MEMBERS_STALE_MS = 5 * 60_000; -export const ORG_MEMBERS_QUERY_KEY = ["org-members"] as const; +export const orgMembersQueryKey = (orgId: string | null) => + ["org-members", orgId] as const; /** Members of the current organization, sorted by display name. */ export function useOrgMembers(options?: { enabled?: boolean }): { members: UserBasic[]; isLoading: boolean; isError: boolean; + isComplete: boolean; } { + const currentOrgId = useAuthStateValue((state) => state.currentOrgId); const query = useAuthenticatedQuery( - ORG_MEMBERS_QUERY_KEY, - (client) => client.listOrganizationMembers(), + orgMembersQueryKey(currentOrgId), + (client) => client.listOrganizationMembersWithStatus(), { enabled: options?.enabled ?? true, staleTime: ORG_MEMBERS_STALE_MS, @@ -24,11 +28,16 @@ export function useOrgMembers(options?: { enabled?: boolean }): { ); const members = useMemo( () => - (query.data ?? []) + (query.data?.members ?? []) .map((member) => member.user) .filter((user) => !!user?.email) .sort((a, b) => userDisplayName(a).localeCompare(userDisplayName(b))), [query.data], ); - return { members, isLoading: query.isLoading, isError: query.isError }; + return { + members, + isLoading: query.isLoading, + isError: query.isError, + isComplete: query.data?.isComplete ?? false, + }; } diff --git a/packages/ui/src/features/loops/components/LoopDetailView.tsx b/packages/ui/src/features/loops/components/LoopDetailView.tsx index a5167adc30..21a7fa3d74 100644 --- a/packages/ui/src/features/loops/components/LoopDetailView.tsx +++ b/packages/ui/src/features/loops/components/LoopDetailView.tsx @@ -267,6 +267,7 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { members, isLoading: membersLoading, isError: membersError, + isComplete: membersComplete, } = useOrgMembers({ enabled: loop.visibility === "team" }); const creator = members.find((member) => member.id === loop.created_by_id); let creatorContent: React.ReactNode = null; @@ -281,8 +282,10 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { {userDisplayName(creator)}
); - } else if (loop.visibility === "team") { + } else if (loop.visibility === "team" && membersComplete) { creatorContent = "Former organization member"; + } else if (loop.visibility === "team") { + creatorContent = "Creator unavailable"; } const notificationDestinations = summarizeNotificationDestinations( loop.notifications, diff --git a/packages/ui/src/features/loops/components/LoopRow.tsx b/packages/ui/src/features/loops/components/LoopRow.tsx index 24e30d54d8..4916a64f72 100644 --- a/packages/ui/src/features/loops/components/LoopRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRow.tsx @@ -16,11 +16,13 @@ export function LoopRow({ creator, creatorLoading = false, creatorError = false, + creatorLookupComplete = true, }: { loop: LoopSchemas.Loop; creator?: UserBasic; creatorLoading?: boolean; creatorError?: boolean; + creatorLookupComplete?: boolean; }) { const description = loop.description.trim(); const triggerLabel = loop.triggers.length === 1 ? "trigger" : "triggers"; @@ -32,6 +34,12 @@ export function LoopRow({ let creatorLabel: string | null = null; if (loop.visibility === "team" && creatorError) { creatorLabel = "Creator unavailable"; + } else if ( + loop.visibility === "team" && + !creatorLoading && + !creatorLookupComplete + ) { + creatorLabel = "Creator unavailable"; } else if (loop.visibility === "team" && !creatorLoading) { creatorLabel = creator ? `Created by ${userDisplayName(creator)}` diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index d8bb2fc2e1..f58a6e770f 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -64,6 +64,7 @@ export function LoopsListView() { members, isLoading: membersLoading, isError: membersError, + isComplete: membersComplete, } = useOrgMembers({ enabled: teamLoops.length > 0 }); return ( @@ -75,6 +76,7 @@ export function LoopsListView() { members={members} membersLoading={membersLoading} membersError={membersError} + membersComplete={membersComplete} onStartBlank={startBlankLoop} onStartFromTemplate={startLoopFromTemplate} /> @@ -89,6 +91,7 @@ interface LoopsListViewPresentationProps { members?: UserBasic[]; membersLoading?: boolean; membersError?: boolean; + membersComplete?: boolean; onStartBlank: () => void; onStartFromTemplate: (template: LoopTemplate) => void; } @@ -101,6 +104,7 @@ export function LoopsListViewPresentation({ members = EMPTY_MEMBERS, membersLoading = false, membersError = false, + membersComplete = true, onStartBlank, onStartFromTemplate, }: LoopsListViewPresentationProps) { @@ -175,6 +179,7 @@ export function LoopsListViewPresentation({ members={members} membersLoading={membersLoading} membersError={membersError} + membersComplete={membersComplete} /> ) : null} @@ -205,12 +210,14 @@ function LoopListSection({ members = EMPTY_MEMBERS, membersLoading = false, membersError = false, + membersComplete = true, }: { title: string; loops: LoopSchemas.Loop[]; members?: UserBasic[]; membersLoading?: boolean; membersError?: boolean; + membersComplete?: boolean; }) { return ( @@ -225,6 +232,7 @@ function LoopListSection({ creator={members.find((member) => member.id === loop.created_by_id)} creatorLoading={membersLoading} creatorError={membersError} + creatorLookupComplete={membersComplete} /> ))}