From af38871ff129f05b3f4f293fadc7008adf87ceab Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Thu, 30 Jul 2026 13:13:56 -0700 Subject: [PATCH 1/2] fix(loops): restore loops registry in personal space Generated-By: PostHog Code Task-Id: 0b782cef-7a2c-4061-b4ac-3c2908a096ba --- .../components/WebsiteChannelLoops.test.tsx | 65 +++++++++++++++++++ .../canvas/components/WebsiteChannelLoops.tsx | 37 ++++++++--- 2 files changed, 94 insertions(+), 8 deletions(-) create mode 100644 packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx 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..97ed3a6082 --- /dev/null +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx @@ -0,0 +1,65 @@ +import { render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; + +vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ + useChannels: () => ({ + channels: [{ id: "personal-space", name: "me", path: "/me" }], + isLoading: false, + }), +})); +vi.mock("@posthog/ui/features/canvas/hooks/useChannelsLayout", () => ({ + useChannelsLayout: () => true, +})); +vi.mock("@posthog/ui/features/canvas/components/ChannelHeader", () => ({ + ChannelHeader: () => null, +})); +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: () => ({ data: [], isLoading: false, isError: false }), + useLoopLimits: () => null, +})); +vi.mock("@posthog/ui/features/loops/components/LoopBuilderComposer", () => ({ + LoopBuilderComposer: () => null, +})); +vi.mock("@posthog/ui/features/loops/components/LoopFallbacks", () => ({ + LoopsEmptyNotice: () => null, + LoopsSkeleton: () => null, +})); +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: () =>
Project loops registry
, +})); + +import { WebsiteChannelLoops } from "./WebsiteChannelLoops"; + +describe("WebsiteChannelLoops", () => { + it("shows the project loops registry in the Personal space", () => { + render(); + + expect(screen.getByText("Project loops registry")).toBeInTheDocument(); + }); +}); diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx index 44efad88aa..975204979b 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx @@ -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,32 @@ 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 } = useChannels(); + const channel = channels.find((candidate) => candidate.id === channelId); + + // 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 SpaceAttachedLoops({ + channelId, + contextName, +}: { + channelId: string; + contextName: string; +}) { const { data: loops, isLoading, isError } = useLoops(); const spacesLayout = useChannelsLayout(); const limits = useLoopLimits(); @@ -63,10 +90,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 +136,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 +237,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { ) : ( - + )} From 19177935b8dfd59f707a11b72071d558e4839939 Mon Sep 17 00:00:00 2001 From: Dylan Martin Date: Thu, 30 Jul 2026 13:47:33 -0700 Subject: [PATCH 2/2] fix(loops): preserve personal space context Generated-By: PostHog Code Task-Id: 0b782cef-7a2c-4061-b4ac-3c2908a096ba --- .../components/WebsiteChannelLoops.test.tsx | 46 ++++++++++++++++--- .../canvas/components/WebsiteChannelLoops.tsx | 26 +++++++++-- .../loops/components/LoopsListView.tsx | 15 ++++-- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx index 97ed3a6082..1a816e51cb 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelLoops.test.tsx @@ -1,17 +1,24 @@ import { render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +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: [{ id: "personal-space", name: "me", path: "/me" }], - isLoading: false, + 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: () => null, + ChannelHeader: () =>
Personal space header
, })); vi.mock("@posthog/ui/hooks/useSetHeaderContent", () => ({ useSetHeaderContent: () => {}, @@ -28,7 +35,7 @@ vi.mock("@posthog/ui/features/canvas/hooks/useOrgMembers", () => ({ }), })); vi.mock("@posthog/ui/features/loops/hooks/useLoops", () => ({ - useLoops: () => ({ data: [], isLoading: false, isError: false }), + useLoops: mocks.useLoops, useLoopLimits: () => null, })); vi.mock("@posthog/ui/features/loops/components/LoopBuilderComposer", () => ({ @@ -36,7 +43,7 @@ vi.mock("@posthog/ui/features/loops/components/LoopBuilderComposer", () => ({ })); vi.mock("@posthog/ui/features/loops/components/LoopFallbacks", () => ({ LoopsEmptyNotice: () => null, - LoopsSkeleton: () => null, + LoopsSkeleton: () =>
Loading loops
, })); vi.mock("@posthog/ui/features/loops/components/LoopRow", () => ({ LoopRow: () => null, @@ -51,15 +58,40 @@ vi.mock("@posthog/ui/features/canvas/hooks/useTaskChannels", () => ({ PERSONAL_CHANNEL_NAME: "me", })); vi.mock("@posthog/ui/features/loops/components/LoopsListView", () => ({ - LoopsListView: () =>
Project loops registry
, + 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 975204979b..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, @@ -57,15 +57,26 @@ 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 } = useChannels(); + 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 ; } return ( @@ -76,6 +87,15 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) { ); } +function ChannelLoopsLoading({ headerContent }: { headerContent: ReactNode }) { + useSetHeaderContent(headerContent); + return ( +
+ +
+ ); +} + function SpaceAttachedLoops({ channelId, contextName, 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();