Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions packages/shared/src/analytics-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -145,6 +146,8 @@ export function ChannelsSidebar() {
</Box>
)}

<LoopsPromoCard />

{/* Workspace switcher pinned to the bottom. Its dropdown carries the
Settings entry, so there's no separate Settings row. */}
<Box className="shrink-0 px-2 pb-2">
Expand Down
185 changes: 185 additions & 0 deletions packages/ui/src/features/loops/components/LoopsPromoCard.tsx
Original file line number Diff line number Diff line change
@@ -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 && (
<Box className="shrink-0 px-2 pb-2">
<div className="group relative overflow-hidden rounded-md border border-gray-6 bg-gray-2">
<button
type="button"
className="block w-full text-left transition-colors hover:bg-gray-3"
onClick={openDialog}
>
<div className="flex h-24 items-center justify-center border-gray-6 border-b bg-gray-4">
<img
src={loopHog}
alt=""
className="h-[72px] w-auto object-contain"
/>
</div>
<div className="flex flex-col gap-0.5 px-3 pt-3 pb-3">
<span className="font-medium text-[13px] text-gray-12">
Introducing Loops
</span>
<span className="text-[11px] text-gray-11 leading-snug">
Recurring agent jobs that run in the cloud and report back.
</span>
{/* Rendered as a span: the whole card is already a button, and
nesting real buttons is invalid HTML. */}
<UiButton
asChild
variant="outline"
color="gray"
size="1"
className="mt-2 self-start"
>
<span>Learn more</span>
</UiButton>
</div>
</button>
<button
type="button"
aria-label="Dismiss Loops announcement"
title="Dismiss"
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"
onClick={handleDismiss}
>
<XIcon size={10} weight="bold" />
</button>
</div>
</Box>
)}

<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="sm:max-w-md">
<div className="flex h-48 items-center justify-center border-gray-6 border-b bg-gray-4">
<img src={loopHog} alt="" className="h-36 w-auto object-contain" />
</div>
<div className="flex flex-col gap-4 px-5 pt-4 pb-5">
<div className="flex flex-col gap-1.5">
<DialogTitle className="font-semibold text-[17px] text-gray-12 tracking-tight">
Introducing Loops
</DialogTitle>
<DialogDescription className="text-[13px] text-gray-11 leading-relaxed">
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.
</DialogDescription>
</div>
<div className="flex flex-col gap-2.5">
<span className="font-medium text-[11px] text-gray-10 uppercase tracking-wide">
Things to try
</span>
<ul className="flex flex-col gap-2">
{EXAMPLES.map(({ icon: ExampleIcon, label }) => (
<li key={label} className="flex items-center gap-2.5">
<span className="flex size-6 shrink-0 items-center justify-center rounded-(--radius-2) bg-(--gray-a3) text-gray-11">
<ExampleIcon size={13} />
</span>
<span className="text-[13px] text-gray-11">{label}</span>
</li>
))}
</ul>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button variant="outline" size="sm" onClick={handleNotNow}>
Not now
</Button>
<Button variant="primary" size="sm" onClick={handleLearnMore}>
Try now
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</>
);
}
48 changes: 48 additions & 0 deletions packages/ui/src/features/loops/loopsPromoStore.ts
Original file line number Diff line number Diff line change
@@ -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<LoopsPromoState>()(
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 });
},
},
),
);
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -100,6 +101,7 @@ export function AdvancedSettings() {
useOnboardingStore.getState().resetOnboarding();
useSetupStore.getState().resetSetup();
useTourStore.getState().resetTours();
useLoopsPromoStore.getState().reset();
}}
>
Reset
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -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",
Expand Down
6 changes: 3 additions & 3 deletions packages/ui/src/features/sidebar/constants.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
),
);
});
Expand Down
12 changes: 6 additions & 6 deletions packages/ui/src/features/sidebar/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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;
Expand Down
Loading