Skip to content
This repository was archived by the owner on Aug 6, 2026. It is now read-only.
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { render, screen } from "@testing-library/react";
import type { ReactNode } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const mocks = vi.hoisted(() => ({
channels: [{ id: "personal-space", name: "me", path: "/me" }],
channelsLoading: false,
useLoops: vi.fn(() => ({ data: [], isLoading: false, isError: false })),
}));

vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({
useChannels: () => ({
channels: mocks.channels,
isLoading: mocks.channelsLoading,
}),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({
useChannelsLayout: () => true,
}));
vi.mock("@posthog/ui/features/canvas/components/ChannelHeader", () => ({
ChannelHeader: () => <div>Personal space header</div>,
}));
vi.mock("@posthog/ui/hooks/useSetHeaderContent", () => ({
useSetHeaderContent: () => {},
}));
vi.mock("@posthog/ui/router/navigationBridge", () => ({
navigateToNewLoop: vi.fn(),
}));
vi.mock("@posthog/ui/features/canvas/hooks/useOrgMembers", () => ({
useOrgMembers: () => ({
members: [],
isLoading: false,
isError: false,
isComplete: true,
}),
}));
vi.mock("@posthog/ui/features/loops/hooks/useLoops", () => ({
useLoops: mocks.useLoops,
useLoopLimits: () => null,
}));
vi.mock("@posthog/ui/features/loops/components/LoopBuilderComposer", () => ({
LoopBuilderComposer: () => null,
}));
vi.mock("@posthog/ui/features/loops/components/LoopFallbacks", () => ({
LoopsEmptyNotice: () => null,
LoopsSkeleton: () => <div>Loading loops</div>,
}));
vi.mock("@posthog/ui/features/loops/components/LoopRow", () => ({
LoopRow: () => null,
}));
vi.mock("@posthog/ui/features/loops/components/LoopsEmptyState", () => ({
LoopsEmptyState: () => null,
}));
vi.mock("@posthog/ui/features/loops/components/LoopTemplatesSection", () => ({
LoopTemplatesSection: () => null,
}));
vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({
PERSONAL_CHANNEL_NAME: "me",
}));
vi.mock("@posthog/ui/features/loops/components/LoopsListView", () => ({
LoopsListView: ({ headerContent }: { headerContent?: ReactNode }) => (
<div>
{headerContent}
Project loops registry
</div>
),
}));

import { WebsiteChannelLoops } from "./WebsiteChannelLoops";

describe("WebsiteChannelLoops", () => {
beforeEach(() => {
mocks.channels = [{ id: "personal-space", name: "me", path: "/me" }];
mocks.channelsLoading = false;
mocks.useLoops.mockClear();
});

it("shows the project loops registry in the Personal space", () => {
render(<WebsiteChannelLoops channelId="personal-space" />);

expect(screen.getByText("Project loops registry")).toBeInTheDocument();
expect(screen.getByText("Personal space header")).toBeInTheDocument();
});

it("waits for the Personal space to resolve before choosing a list", () => {
mocks.channels = [];
mocks.channelsLoading = true;

render(<WebsiteChannelLoops channelId="personal-space" />);

expect(screen.getByText("Loading loops")).toBeInTheDocument();
expect(
screen.queryByText("Project loops registry"),
).not.toBeInTheDocument();
expect(mocks.useLoops).not.toHaveBeenCalled();
});
});
59 changes: 50 additions & 9 deletions packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import {
} from "@posthog/ui/primitives/PageHeader";
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { useMemo } from "react";
import { type ReactNode, useMemo } from "react";
import { LoopBuilderComposer } from "../../loops/components/LoopBuilderComposer";
import {
LoopsEmptyNotice,
LoopsSkeleton,
} from "../../loops/components/LoopFallbacks";
import { LoopRow } from "../../loops/components/LoopRow";
import { LoopsEmptyState } from "../../loops/components/LoopsEmptyState";
import { LoopsListView } from "../../loops/components/LoopsListView";
import { LoopTemplatesSection } from "../../loops/components/LoopTemplatesSection";
import { useLoopLimits, useLoops } from "../../loops/hooks/useLoops";
import { useLoopDraftStore } from "../../loops/loopDraftStore";
Expand Down Expand Up @@ -56,17 +57,59 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] {
* composer pinned at the bottom), but the build surface is tuned to automations that feed
* this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */
export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
const { channels, isLoading } = useChannels();
const channel = channels.find((candidate) => candidate.id === channelId);
const headerContent = useMemo(
() => <ChannelHeader channelId={channelId} page="loops" />,
[channelId],
);

// Don't mount the scoped scene while the route's space is unresolved. In
// particular, that would flash a raw-id empty state for Personal before the
// channel query identifies it as the project-level loops registry.
if (isLoading && !channel) {
return <ChannelLoopsLoading headerContent={headerContent} />;
}

// The Personal space is the project-level home for loops in the spaces
// layout. API-created and other unattached loops have no context_target, so
// rendering the space-scoped list here incorrectly produces the global
// "Create your first loop" empty state while those loops already exist.
if (channel?.name === PERSONAL_CHANNEL_NAME) {
return <LoopsListView headerContent={headerContent} />;
}

return (
<SpaceAttachedLoops
channelId={channelId}
contextName={channel?.name ?? channelId}
/>
);
}

function ChannelLoopsLoading({ headerContent }: { headerContent: ReactNode }) {
useSetHeaderContent(headerContent);
return (
<div className="mx-auto w-full max-w-5xl px-8 py-8">
<LoopsSkeleton />
</div>
);
}

function SpaceAttachedLoops({
channelId,
contextName,
}: {
channelId: string;
contextName: string;
}) {
const { data: loops, isLoading, isError } = useLoops();
const spacesLayout = useChannelsLayout();
const limits = useLoopLimits();
const limitReason =
limits?.atLimit === true
? `You've reached the limit of ${limits.max} loops for this project. Delete one to add another.`
: null;
const { channels } = useChannels();
const channel = channels.find((c) => c.id === channelId);
const contextName = channel?.name ?? channelId;
const isPersonal = contextName === PERSONAL_CHANNEL_NAME;

useSetHeaderContent(
useMemo(
Expand Down Expand Up @@ -113,7 +156,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
navigateToNewLoop();
};

const title = isPersonal ? "Loops" : `Automate #${contextName}`;
const title = `Automate #${contextName}`;
const description =
"Put your work on autopilot. Loops run on a schedule, on an API call, or when something happens on GitHub. You can finally close the laptop!";
const createButton = (
Expand Down Expand Up @@ -214,9 +257,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
</Flex>
</Flex>
) : (
<LoopsEmptyState
contextName={isPersonal ? undefined : contextName}
/>
<LoopsEmptyState contextName={contextName} />
)}

<LoopTemplatesSection onSelect={startFromTemplate} />
Expand Down
15 changes: 10 additions & 5 deletions packages/ui/src/features/loops/components/LoopsListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import {
} from "@posthog/ui/router/navigationBridge";
import { track } from "@posthog/ui/shell/analytics";
import { Flex, Text } from "@radix-ui/themes";
import { useEffect, useRef, useState } from "react";
import { type ReactNode, useEffect, useRef, useState } from "react";
import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions";
import { useLoopLimits, useLoops } from "../hooks/useLoops";
import {
Expand Down Expand Up @@ -72,7 +72,11 @@ function startLoopFromTemplate(template: LoopTemplate): void {
navigateToNewLoop();
}

export function LoopsListView() {
export function LoopsListView({
headerContent = null,
}: {
headerContent?: ReactNode;
}) {
const { data: loops, isLoading, isError, error } = useLoops();
const authenticatedClient = useOptionalAuthenticatedClient();
const {
Expand All @@ -91,9 +95,10 @@ export function LoopsListView() {
listError = currentUserQueryError;
}

// The page names itself (in-page header / title block), so it pushes no
// breadcrumb row — only a space-attached loop scene has a parent to show.
useSetHeaderContent(null);
// The standalone page names itself in-page and has no breadcrumb. When the
// registry is hosted inside a space, its caller supplies that navigation
// context instead.
useSetHeaderContent(headerContent);

const { sessions: builderSessions, isSettled: builderSessionsSettled } =
useLoopBuilderSessions();
Expand Down
Loading