diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index 8811b48d0e..fed7c06e45 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -1254,6 +1254,11 @@ export const ANALYTICS_EVENTS = { // Autoresearch events AUTORESEARCH_ARMED: "Autoresearch armed", AUTORESEARCH_RUN_STARTED: "Autoresearch run started", + + // Loops promo events + LOOPS_PROMO_OPENED: "Loops promo opened", + LOOPS_PROMO_DISMISSED: "Loops promo dismissed", + LOOPS_PROMO_LEARN_MORE_CLICKED: "Loops promo learn more clicked", } as const; // Event property mapping @@ -1414,6 +1419,11 @@ export type EventPropertyMap = { // Autoresearch events [ANALYTICS_EVENTS.AUTORESEARCH_ARMED]: AutoresearchArmedProperties; [ANALYTICS_EVENTS.AUTORESEARCH_RUN_STARTED]: AutoresearchRunStartedProperties; + + // Loops promo events + [ANALYTICS_EVENTS.LOOPS_PROMO_OPENED]: never; + [ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED]: never; + [ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED]: never; }; /** diff --git a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx index 1ed590fa55..3afc553050 100644 --- a/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx +++ b/packages/ui/src/features/canvas/components/ChannelsSidebar.tsx @@ -6,6 +6,7 @@ import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab" import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList"; import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher"; import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu"; @@ -145,6 +146,8 @@ export function ChannelsSidebar() { )} + + {/* Workspace switcher pinned to the bottom. Its dropdown carries the Settings entry, so there's no separate Settings row. */} diff --git a/packages/ui/src/features/loops/components/LoopsPromoCard.tsx b/packages/ui/src/features/loops/components/LoopsPromoCard.tsx new file mode 100644 index 0000000000..c15d92a7fe --- /dev/null +++ b/packages/ui/src/features/loops/components/LoopsPromoCard.tsx @@ -0,0 +1,185 @@ +import { + GitPullRequestIcon, + type Icon, + LifebuoyIcon, + ListChecksIcon, + SunIcon, + TestTubeIcon, + XIcon, +} from "@phosphor-icons/react"; +import { + Button, + Dialog, + DialogContent, + DialogDescription, + DialogTitle, +} from "@posthog/quill"; +import { LOOPS_FLAG } from "@posthog/shared"; +import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events"; +import { loopHog } from "@posthog/ui/assets/hedgehogs"; +import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useLoopsPromoStore } from "@posthog/ui/features/loops/loopsPromoStore"; +import { Button as UiButton } from "@posthog/ui/primitives/Button"; +import { navigateToLoops } from "@posthog/ui/router/navigationBridge"; +import { useAppView } from "@posthog/ui/router/useAppView"; +import { track } from "@posthog/ui/shell/analytics"; +import { Box } from "@radix-ui/themes"; +import { useEffect, useState } from "react"; + +const EXAMPLES: { icon: Icon; label: string }[] = [ + { + icon: GitPullRequestIcon, + label: "Digest open pull requests and flag what needs attention", + }, + { + icon: TestTubeIcon, + label: "Track down flaky tests and summarize CI failures", + }, + { + icon: ListChecksIcon, + label: "Triage new issues and flag likely duplicates", + }, + { icon: SunIcon, label: "Post a standup summary every weekday morning" }, + { + icon: LifebuoyIcon, + label: "Review support tickets and surface the urgent ones", + }, +]; + +export function LoopsPromoCard() { + const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV); + const dismissed = useLoopsPromoStore((state) => state.dismissed); + const hasHydrated = useLoopsPromoStore((state) => state._hasHydrated); + const dismiss = useLoopsPromoStore((state) => state.dismiss); + const [dialogOpen, setDialogOpen] = useState(false); + + // Reaching the Loops page on their own means the promo did its job (or was + // never needed), so it retires the card the same way answering the dialog does. + const view = useAppView(); + const onLoopsPage = view.type === "loops"; + useEffect(() => { + if (onLoopsPage && hasHydrated && !dismissed) dismiss(); + }, [onLoopsPage, hasHydrated, dismissed, dismiss]); + + if (!loopsEnabled || !hasHydrated || (dismissed && !dialogOpen)) return null; + + const openDialog = () => { + track(ANALYTICS_EVENTS.LOOPS_PROMO_OPENED); + setDialogOpen(true); + }; + + const handleDismiss = () => { + track(ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED); + dismiss(); + }; + + // Answering the dialog either way retires the card: it exists to get the + // user into this dialog once, not to nag after they've decided. + const handleNotNow = () => { + track(ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED); + setDialogOpen(false); + dismiss(); + }; + + const handleLearnMore = () => { + track(ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED); + setDialogOpen(false); + dismiss(); + navigateToLoops(); + }; + + return ( + <> + {!dismissed && ( + +
+ + +
+
+ )} + + + +
+ +
+
+
+ + Introducing Loops + + + Describe a job once and it keeps running in the cloud, on a + schedule or when something happens in your repos, even with your + laptop closed. Every run reports back. + +
+
+ + Things to try + +
    + {EXAMPLES.map(({ icon: ExampleIcon, label }) => ( +
  • + + + + {label} +
  • + ))} +
+
+
+ + +
+
+
+
+ + ); +} diff --git a/packages/ui/src/features/loops/loopsPromoStore.ts b/packages/ui/src/features/loops/loopsPromoStore.ts new file mode 100644 index 0000000000..918dce9a7b --- /dev/null +++ b/packages/ui/src/features/loops/loopsPromoStore.ts @@ -0,0 +1,48 @@ +import { + electronStorage, + flushRendererStateWrites, +} from "@posthog/ui/shell/rendererStorage"; +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +interface LoopsPromoState { + dismissed: boolean; + // Hydration is async (Electron storage over IPC); the card must not flash + // for users whose persisted dismissal hasn't been read back yet. + _hasHydrated: boolean; + dismiss: () => void; + reset: () => void; + setHasHydrated: (hydrated: boolean) => void; +} + +export const useLoopsPromoStore = create()( + persist( + (set) => ({ + dismissed: false, + _hasHydrated: false, + // Flushed immediately: the debounced write could otherwise be lost if + // the window closes right after the click, resurrecting the card. + dismiss: () => { + set({ dismissed: true }); + void flushRendererStateWrites(); + }, + reset: () => { + set({ dismissed: false }); + void flushRendererStateWrites(); + }, + setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }), + }), + { + name: "posthog-code-loops-promo-dismissed", + storage: electronStorage, + partialize: (state) => ({ dismissed: state.dismissed }), + onRehydrateStorage: () => (state) => { + if (state) { + state.setHasHydrated(true); + return; + } + useLoopsPromoStore.setState({ _hasHydrated: true }); + }, + }, + ), +); diff --git a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx index 12843aba89..0a6afb8777 100644 --- a/packages/ui/src/features/settings/sections/AdvancedSettings.tsx +++ b/packages/ui/src/features/settings/sections/AdvancedSettings.tsx @@ -1,6 +1,7 @@ import { useServiceOptional } from "@posthog/di/react"; import { useHostTRPC } from "@posthog/host-router/react"; import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag"; +import { useLoopsPromoStore } from "@posthog/ui/features/loops/loopsPromoStore"; import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore"; import { DEV_MODE_CLIENT, @@ -100,6 +101,7 @@ export function AdvancedSettings() { useOnboardingStore.getState().resetOnboarding(); useSetupStore.getState().resetSetup(); useTourStore.getState().resetTours(); + useLoopsPromoStore.getState().reset(); }} > Reset diff --git a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx index 2267056bd8..b94f3f3458 100644 --- a/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx +++ b/packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx @@ -153,10 +153,10 @@ describe("CustomizeSidebarDialog", () => { }); it("renders rows in the stored order", () => { - useSidebarStore.setState({ navItemOrder: ["loops", "search"] }); + useSidebarStore.setState({ navItemOrder: ["configure", "search"] }); renderDialog(); - expect(rowLabels().slice(0, 2)).toEqual(["Loops", "Search"]); + expect(rowLabels().slice(0, 2)).toEqual(["Configure", "Search"]); }); it("previews on dragover and persists only on drop", () => { @@ -176,12 +176,12 @@ describe("CustomizeSidebarDialog", () => { "search", "inbox", "agents", + "loops", "mcp-servers", "command-center", "contexts", "activity", "configure", - "loops", ]); expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_REORDERED, { item: "skills", diff --git a/packages/ui/src/features/sidebar/constants.test.ts b/packages/ui/src/features/sidebar/constants.test.ts index 666d62e832..09c6e8d35f 100644 --- a/packages/ui/src/features/sidebar/constants.test.ts +++ b/packages/ui/src/features/sidebar/constants.test.ts @@ -30,12 +30,12 @@ describe("orderedNavItems", () => { }); it("puts stored ids first and appends the rest in default order", () => { - const ids = orderedNavItems(["loops", "search"]).map((item) => item.id); + const ids = orderedNavItems(["configure", "search"]).map((item) => item.id); - expect(ids.slice(0, 2)).toEqual(["loops", "search"]); + expect(ids.slice(0, 2)).toEqual(["configure", "search"]); expect(ids.slice(2)).toEqual( CUSTOMIZABLE_NAV_ITEM_IDS.filter( - (id) => id !== "loops" && id !== "search", + (id) => id !== "configure" && id !== "search", ), ); }); diff --git a/packages/ui/src/features/sidebar/constants.ts b/packages/ui/src/features/sidebar/constants.ts index dbb63adf30..58bfb48ddd 100644 --- a/packages/ui/src/features/sidebar/constants.ts +++ b/packages/ui/src/features/sidebar/constants.ts @@ -22,6 +22,12 @@ export const CUSTOMIZABLE_NAV_ITEMS = [ analyticsId: "skills", defaultVisible: true, }, + { + id: "loops", + label: "Loops", + analyticsId: "loops", + defaultVisible: true, + }, { id: "mcp-servers", label: "MCP servers", @@ -52,12 +58,6 @@ export const CUSTOMIZABLE_NAV_ITEMS = [ analyticsId: "configure", defaultVisible: true, }, - { - id: "loops", - label: "Loops", - analyticsId: "loops", - defaultVisible: true, - }, ] as const satisfies readonly { id: string; label: string;