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: 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 334692f544..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,16 +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, @@ -23,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 }; + 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 4c24e18f27..21a7fa3d74 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,33 @@ function loopStatusBadgeVariant( function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { const displayModel = useLoopDisplayModel(loop.runtime_adapter, loop.model); + const { + 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; + 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" && membersComplete) { + creatorContent = "Former organization member"; + } else if (loop.visibility === "team") { + creatorContent = "Creator unavailable"; + } + const notificationDestinations = summarizeNotificationDestinations( + loop.notifications, + ); return ( @@ -287,6 +318,10 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) { : "None (connector-only loop)"} + {loop.visibility === "team" ? ( + {creatorContent} + ) : null} + {loop.triggers.length === 0 ? ( "No triggers configured" @@ -301,6 +336,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..4916a64f72 100644 --- a/packages/ui/src/features/loops/components/LoopRow.tsx +++ b/packages/ui/src/features/loops/components/LoopRow.tsx @@ -1,11 +1,60 @@ 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, + 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"; + const triggerSummary = + loop.triggers.length === 0 + ? "No triggers configured" + : `${loop.triggers.length} ${triggerLabel}`; + + 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)}` + : "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 ( {loopStatusLabel(loop)} {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.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx new file mode 100644 index 0000000000..77f6c4a872 --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -0,0 +1,175 @@ +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 POSTHOG_HOG: UserBasic = { + id: 2, + uuid: "user-posthog-hog", + email: "hog@example.com", + first_name: "PostHog", + last_name: "Hog", +}; + +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 : POSTHOG_HOG.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-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", + 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"), + }), + 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 = { + title: "Loops/LoopsListView", + component: LoopsListViewPresentation, + parameters: { layout: "fullscreen" }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + loops: MIXED_LOOPS, + members: [POSTHOG_HOG, PAUL], + onStartBlank: () => {}, + onStartFromTemplate: () => {}, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Comprehensive: Story = {}; + +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" ? POSTHOG_HOG.id : 1, + enabled: index % 7 !== 0, + notifications: notifications(channels, `team-loop-${index + 1}`), + }); + }), + }, +}; diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 8e6021831a..f58a6e770f 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -1,4 +1,7 @@ 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"; import { Button } from "@posthog/ui/primitives/Button"; import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge"; @@ -19,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(); @@ -42,18 +59,57 @@ export function LoopsListView() { useSetHeaderContent(headerContent); const allLoops = loops ?? []; + const teamLoops = allLoops.filter((loop) => loop.visibility === "team"); + const { + members, + isLoading: membersLoading, + isError: membersError, + isComplete: membersComplete, + } = useOrgMembers({ enabled: teamLoops.length > 0 }); + + return ( + + ); +} - const startBlank = () => { - useLoopDraftStore.getState().setPrefill(null); - navigateToNewLoop(); - }; +interface LoopsListViewPresentationProps { + loops: LoopSchemas.Loop[]; + isLoading?: boolean; + error?: unknown; + limitReason?: string | null; + members?: UserBasic[]; + membersLoading?: boolean; + membersError?: boolean; + membersComplete?: boolean; + onStartBlank: () => void; + onStartFromTemplate: (template: LoopTemplate) => void; +} - const startFromTemplate = (template: LoopTemplate) => { - useLoopDraftStore - .getState() - .setPrefill({ description: template.description, ...template.build() }); - navigateToNewLoop(); - }; +export function LoopsListViewPresentation({ + loops, + isLoading = false, + error = null, + limitReason = null, + members = EMPTY_MEMBERS, + membersLoading = false, + membersError = false, + membersComplete = true, + onStartBlank, + onStartFromTemplate, +}: LoopsListViewPresentationProps) { + const personalLoops = loops.filter((loop) => loop.visibility === "personal"); + const teamLoops = loops.filter((loop) => loop.visibility === "team"); return ( @@ -91,7 +147,7 @@ export function LoopsListView() { variant="soft" color="gray" size="2" - onClick={startBlank} + onClick={onStartBlank} disabled={limitReason != null} disabledReason={limitReason} > @@ -102,7 +158,7 @@ export function LoopsListView() { {isLoading ? ( - ) : isError ? ( + ) : error ? ( - ) : allLoops.length > 0 ? ( - - - Your loops - - - {allLoops.map((loop) => ( - - ))} - + ) : loops.length > 0 ? ( + + {personalLoops.length > 0 ? ( + + ) : null} + {teamLoops.length > 0 ? ( + + ) : null} ) : ( )} - + @@ -142,3 +203,39 @@ export function LoopsListView() { ); } + +function LoopListSection({ + title, + loops, + members = EMPTY_MEMBERS, + membersLoading = false, + membersError = false, + membersComplete = true, +}: { + title: string; + loops: LoopSchemas.Loop[]; + members?: UserBasic[]; + membersLoading?: boolean; + membersError?: boolean; + membersComplete?: boolean; +}) { + return ( + + + {title} + + + {loops.map((loop) => ( + member.id === loop.created_by_id)} + creatorLoading={membersLoading} + creatorError={membersError} + creatorLookupComplete={membersComplete} + /> + ))} + + + ); +} 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") {