Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.

Commit 9ca0258

Browse files
authored
feat(loops): Add Introducing Loops promo card (#3675)
1 parent 8e59125 commit 9ca0258

8 files changed

Lines changed: 260 additions & 12 deletions

File tree

packages/shared/src/analytics-events.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1254,6 +1254,11 @@ export const ANALYTICS_EVENTS = {
12541254
// Autoresearch events
12551255
AUTORESEARCH_ARMED: "Autoresearch armed",
12561256
AUTORESEARCH_RUN_STARTED: "Autoresearch run started",
1257+
1258+
// Loops promo events
1259+
LOOPS_PROMO_OPENED: "Loops promo opened",
1260+
LOOPS_PROMO_DISMISSED: "Loops promo dismissed",
1261+
LOOPS_PROMO_LEARN_MORE_CLICKED: "Loops promo learn more clicked",
12571262
} as const;
12581263

12591264
// Event property mapping
@@ -1414,6 +1419,11 @@ export type EventPropertyMap = {
14141419
// Autoresearch events
14151420
[ANALYTICS_EVENTS.AUTORESEARCH_ARMED]: AutoresearchArmedProperties;
14161421
[ANALYTICS_EVENTS.AUTORESEARCH_RUN_STARTED]: AutoresearchRunStartedProperties;
1422+
1423+
// Loops promo events
1424+
[ANALYTICS_EVENTS.LOOPS_PROMO_OPENED]: never;
1425+
[ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED]: never;
1426+
[ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED]: never;
14171427
};
14181428

14191429
/**

packages/ui/src/features/canvas/components/ChannelsSidebar.tsx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ChannelsFab } from "@posthog/ui/features/canvas/components/ChannelsFab"
66
import { ChannelsList } from "@posthog/ui/features/canvas/components/ChannelsList";
77
import { useChannelsSidebarStore } from "@posthog/ui/features/canvas/components/channelsSidebarStore";
88
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
9+
import { LoopsPromoCard } from "@posthog/ui/features/loops/components/LoopsPromoCard";
910
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
1011
import { ProjectSwitcher } from "@posthog/ui/features/sidebar/components/ProjectSwitcher";
1112
import { SidebarMenu } from "@posthog/ui/features/sidebar/components/SidebarMenu";
@@ -145,6 +146,8 @@ export function ChannelsSidebar() {
145146
</Box>
146147
)}
147148

149+
<LoopsPromoCard />
150+
148151
{/* Workspace switcher pinned to the bottom. Its dropdown carries the
149152
Settings entry, so there's no separate Settings row. */}
150153
<Box className="shrink-0 px-2 pb-2">
Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import {
2+
GitPullRequestIcon,
3+
type Icon,
4+
LifebuoyIcon,
5+
ListChecksIcon,
6+
SunIcon,
7+
TestTubeIcon,
8+
XIcon,
9+
} from "@phosphor-icons/react";
10+
import {
11+
Button,
12+
Dialog,
13+
DialogContent,
14+
DialogDescription,
15+
DialogTitle,
16+
} from "@posthog/quill";
17+
import { LOOPS_FLAG } from "@posthog/shared";
18+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
19+
import { loopHog } from "@posthog/ui/assets/hedgehogs";
20+
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
21+
import { useLoopsPromoStore } from "@posthog/ui/features/loops/loopsPromoStore";
22+
import { Button as UiButton } from "@posthog/ui/primitives/Button";
23+
import { navigateToLoops } from "@posthog/ui/router/navigationBridge";
24+
import { useAppView } from "@posthog/ui/router/useAppView";
25+
import { track } from "@posthog/ui/shell/analytics";
26+
import { Box } from "@radix-ui/themes";
27+
import { useEffect, useState } from "react";
28+
29+
const EXAMPLES: { icon: Icon; label: string }[] = [
30+
{
31+
icon: GitPullRequestIcon,
32+
label: "Digest open pull requests and flag what needs attention",
33+
},
34+
{
35+
icon: TestTubeIcon,
36+
label: "Track down flaky tests and summarize CI failures",
37+
},
38+
{
39+
icon: ListChecksIcon,
40+
label: "Triage new issues and flag likely duplicates",
41+
},
42+
{ icon: SunIcon, label: "Post a standup summary every weekday morning" },
43+
{
44+
icon: LifebuoyIcon,
45+
label: "Review support tickets and surface the urgent ones",
46+
},
47+
];
48+
49+
export function LoopsPromoCard() {
50+
const loopsEnabled = useFeatureFlag(LOOPS_FLAG, import.meta.env.DEV);
51+
const dismissed = useLoopsPromoStore((state) => state.dismissed);
52+
const hasHydrated = useLoopsPromoStore((state) => state._hasHydrated);
53+
const dismiss = useLoopsPromoStore((state) => state.dismiss);
54+
const [dialogOpen, setDialogOpen] = useState(false);
55+
56+
// Reaching the Loops page on their own means the promo did its job (or was
57+
// never needed), so it retires the card the same way answering the dialog does.
58+
const view = useAppView();
59+
const onLoopsPage = view.type === "loops";
60+
useEffect(() => {
61+
if (onLoopsPage && hasHydrated && !dismissed) dismiss();
62+
}, [onLoopsPage, hasHydrated, dismissed, dismiss]);
63+
64+
if (!loopsEnabled || !hasHydrated || (dismissed && !dialogOpen)) return null;
65+
66+
const openDialog = () => {
67+
track(ANALYTICS_EVENTS.LOOPS_PROMO_OPENED);
68+
setDialogOpen(true);
69+
};
70+
71+
const handleDismiss = () => {
72+
track(ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED);
73+
dismiss();
74+
};
75+
76+
// Answering the dialog either way retires the card: it exists to get the
77+
// user into this dialog once, not to nag after they've decided.
78+
const handleNotNow = () => {
79+
track(ANALYTICS_EVENTS.LOOPS_PROMO_DISMISSED);
80+
setDialogOpen(false);
81+
dismiss();
82+
};
83+
84+
const handleLearnMore = () => {
85+
track(ANALYTICS_EVENTS.LOOPS_PROMO_LEARN_MORE_CLICKED);
86+
setDialogOpen(false);
87+
dismiss();
88+
navigateToLoops();
89+
};
90+
91+
return (
92+
<>
93+
{!dismissed && (
94+
<Box className="shrink-0 px-2 pb-2">
95+
<div className="group relative overflow-hidden rounded-md border border-gray-6 bg-gray-2">
96+
<button
97+
type="button"
98+
className="block w-full text-left transition-colors hover:bg-gray-3"
99+
onClick={openDialog}
100+
>
101+
<div className="flex h-24 items-center justify-center border-gray-6 border-b bg-gray-4">
102+
<img
103+
src={loopHog}
104+
alt=""
105+
className="h-[72px] w-auto object-contain"
106+
/>
107+
</div>
108+
<div className="flex flex-col gap-0.5 px-3 pt-3 pb-3">
109+
<span className="font-medium text-[13px] text-gray-12">
110+
Introducing Loops
111+
</span>
112+
<span className="text-[11px] text-gray-11 leading-snug">
113+
Recurring agent jobs that run in the cloud and report back.
114+
</span>
115+
{/* Rendered as a span: the whole card is already a button, and
116+
nesting real buttons is invalid HTML. */}
117+
<UiButton
118+
asChild
119+
variant="outline"
120+
color="gray"
121+
size="1"
122+
className="mt-2 self-start"
123+
>
124+
<span>Learn more</span>
125+
</UiButton>
126+
</div>
127+
</button>
128+
<button
129+
type="button"
130+
aria-label="Dismiss Loops announcement"
131+
title="Dismiss"
132+
className="absolute top-1.5 right-1.5 rounded-full bg-(--gray-a3) p-1 text-gray-11 opacity-0 transition-all hover:bg-(--gray-a5) hover:text-gray-12 focus-visible:opacity-100 group-hover:opacity-100"
133+
onClick={handleDismiss}
134+
>
135+
<XIcon size={10} weight="bold" />
136+
</button>
137+
</div>
138+
</Box>
139+
)}
140+
141+
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
142+
<DialogContent className="sm:max-w-md">
143+
<div className="flex h-48 items-center justify-center border-gray-6 border-b bg-gray-4">
144+
<img src={loopHog} alt="" className="h-36 w-auto object-contain" />
145+
</div>
146+
<div className="flex flex-col gap-4 px-5 pt-4 pb-5">
147+
<div className="flex flex-col gap-1.5">
148+
<DialogTitle className="font-semibold text-[17px] text-gray-12 tracking-tight">
149+
Introducing Loops
150+
</DialogTitle>
151+
<DialogDescription className="text-[13px] text-gray-11 leading-relaxed">
152+
Describe a job once and it keeps running in the cloud, on a
153+
schedule or when something happens in your repos, even with your
154+
laptop closed. Every run reports back.
155+
</DialogDescription>
156+
</div>
157+
<div className="flex flex-col gap-2.5">
158+
<span className="font-medium text-[11px] text-gray-10 uppercase tracking-wide">
159+
Things to try
160+
</span>
161+
<ul className="flex flex-col gap-2">
162+
{EXAMPLES.map(({ icon: ExampleIcon, label }) => (
163+
<li key={label} className="flex items-center gap-2.5">
164+
<span className="flex size-6 shrink-0 items-center justify-center rounded-(--radius-2) bg-(--gray-a3) text-gray-11">
165+
<ExampleIcon size={13} />
166+
</span>
167+
<span className="text-[13px] text-gray-11">{label}</span>
168+
</li>
169+
))}
170+
</ul>
171+
</div>
172+
<div className="flex justify-end gap-2 pt-1">
173+
<Button variant="outline" size="sm" onClick={handleNotNow}>
174+
Not now
175+
</Button>
176+
<Button variant="primary" size="sm" onClick={handleLearnMore}>
177+
Try now
178+
</Button>
179+
</div>
180+
</div>
181+
</DialogContent>
182+
</Dialog>
183+
</>
184+
);
185+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import {
2+
electronStorage,
3+
flushRendererStateWrites,
4+
} from "@posthog/ui/shell/rendererStorage";
5+
import { create } from "zustand";
6+
import { persist } from "zustand/middleware";
7+
8+
interface LoopsPromoState {
9+
dismissed: boolean;
10+
// Hydration is async (Electron storage over IPC); the card must not flash
11+
// for users whose persisted dismissal hasn't been read back yet.
12+
_hasHydrated: boolean;
13+
dismiss: () => void;
14+
reset: () => void;
15+
setHasHydrated: (hydrated: boolean) => void;
16+
}
17+
18+
export const useLoopsPromoStore = create<LoopsPromoState>()(
19+
persist(
20+
(set) => ({
21+
dismissed: false,
22+
_hasHydrated: false,
23+
// Flushed immediately: the debounced write could otherwise be lost if
24+
// the window closes right after the click, resurrecting the card.
25+
dismiss: () => {
26+
set({ dismissed: true });
27+
void flushRendererStateWrites();
28+
},
29+
reset: () => {
30+
set({ dismissed: false });
31+
void flushRendererStateWrites();
32+
},
33+
setHasHydrated: (hydrated) => set({ _hasHydrated: hydrated }),
34+
}),
35+
{
36+
name: "posthog-code-loops-promo-dismissed",
37+
storage: electronStorage,
38+
partialize: (state) => ({ dismissed: state.dismissed }),
39+
onRehydrateStorage: () => (state) => {
40+
if (state) {
41+
state.setHasHydrated(true);
42+
return;
43+
}
44+
useLoopsPromoStore.setState({ _hasHydrated: true });
45+
},
46+
},
47+
),
48+
);

packages/ui/src/features/settings/sections/AdvancedSettings.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useServiceOptional } from "@posthog/di/react";
22
import { useHostTRPC } from "@posthog/host-router/react";
33
import { useFeatureFlag } from "@posthog/ui/features/feature-flags/useFeatureFlag";
4+
import { useLoopsPromoStore } from "@posthog/ui/features/loops/loopsPromoStore";
45
import { useOnboardingStore } from "@posthog/ui/features/onboarding/onboardingStore";
56
import {
67
DEV_MODE_CLIENT,
@@ -100,6 +101,7 @@ export function AdvancedSettings() {
100101
useOnboardingStore.getState().resetOnboarding();
101102
useSetupStore.getState().resetSetup();
102103
useTourStore.getState().resetTours();
104+
useLoopsPromoStore.getState().reset();
103105
}}
104106
>
105107
Reset

packages/ui/src/features/sidebar/components/CustomizeSidebarDialog.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,10 @@ describe("CustomizeSidebarDialog", () => {
153153
});
154154

155155
it("renders rows in the stored order", () => {
156-
useSidebarStore.setState({ navItemOrder: ["loops", "search"] });
156+
useSidebarStore.setState({ navItemOrder: ["configure", "search"] });
157157
renderDialog();
158158

159-
expect(rowLabels().slice(0, 2)).toEqual(["Loops", "Search"]);
159+
expect(rowLabels().slice(0, 2)).toEqual(["Configure", "Search"]);
160160
});
161161

162162
it("previews on dragover and persists only on drop", () => {
@@ -176,12 +176,12 @@ describe("CustomizeSidebarDialog", () => {
176176
"search",
177177
"inbox",
178178
"agents",
179+
"loops",
179180
"mcp-servers",
180181
"command-center",
181182
"contexts",
182183
"activity",
183184
"configure",
184-
"loops",
185185
]);
186186
expect(track).toHaveBeenCalledWith(ANALYTICS_EVENTS.SIDEBAR_REORDERED, {
187187
item: "skills",

packages/ui/src/features/sidebar/constants.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,12 @@ describe("orderedNavItems", () => {
3030
});
3131

3232
it("puts stored ids first and appends the rest in default order", () => {
33-
const ids = orderedNavItems(["loops", "search"]).map((item) => item.id);
33+
const ids = orderedNavItems(["configure", "search"]).map((item) => item.id);
3434

35-
expect(ids.slice(0, 2)).toEqual(["loops", "search"]);
35+
expect(ids.slice(0, 2)).toEqual(["configure", "search"]);
3636
expect(ids.slice(2)).toEqual(
3737
CUSTOMIZABLE_NAV_ITEM_IDS.filter(
38-
(id) => id !== "loops" && id !== "search",
38+
(id) => id !== "configure" && id !== "search",
3939
),
4040
);
4141
});

packages/ui/src/features/sidebar/constants.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ export const CUSTOMIZABLE_NAV_ITEMS = [
2222
analyticsId: "skills",
2323
defaultVisible: true,
2424
},
25+
{
26+
id: "loops",
27+
label: "Loops",
28+
analyticsId: "loops",
29+
defaultVisible: true,
30+
},
2531
{
2632
id: "mcp-servers",
2733
label: "MCP servers",
@@ -52,12 +58,6 @@ export const CUSTOMIZABLE_NAV_ITEMS = [
5258
analyticsId: "configure",
5359
defaultVisible: true,
5460
},
55-
{
56-
id: "loops",
57-
label: "Loops",
58-
analyticsId: "loops",
59-
defaultVisible: true,
60-
},
6161
] as const satisfies readonly {
6262
id: string;
6363
label: string;

0 commit comments

Comments
 (0)