diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx
new file mode 100644
index 0000000000..1a816e51cb
--- /dev/null
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx
@@ -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: () =>
Personal space header
,
+}));
+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: () => Loading loops
,
+}));
+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 }) => (
+
+ {headerContent}
+ Project loops registry
+
+ ),
+}));
+
+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();
+
+ 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();
+
+ expect(screen.getByText("Loading loops")).toBeInTheDocument();
+ expect(
+ screen.queryByText("Project loops registry"),
+ ).not.toBeInTheDocument();
+ expect(mocks.useLoops).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
index 44efad88aa..c9e789ea06 100644
--- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
+++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx
@@ -14,7 +14,7 @@ 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,
@@ -22,6 +22,7 @@ import {
} 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";
@@ -56,6 +57,52 @@ 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(
+ () => ,
+ [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 ;
+ }
+
+ // 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 ;
+ }
+
+ return (
+
+ );
+}
+
+function ChannelLoopsLoading({ headerContent }: { headerContent: ReactNode }) {
+ useSetHeaderContent(headerContent);
+ return (
+
+
+
+ );
+}
+
+function SpaceAttachedLoops({
+ channelId,
+ contextName,
+}: {
+ channelId: string;
+ contextName: string;
+}) {
const { data: loops, isLoading, isError } = useLoops();
const spacesLayout = useChannelsLayout();
const limits = useLoopLimits();
@@ -63,10 +110,6 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
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(
@@ -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 = (
@@ -214,9 +257,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
) : (
-
+
)}
diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx
index a265fb6d51..e9f4be9a66 100644
--- a/packages/ui/src/features/loops/components/LoopsListView.tsx
+++ b/packages/ui/src/features/loops/components/LoopsListView.tsx
@@ -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 {
@@ -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 {
@@ -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();