Skip to content

Commit b6e85c8

Browse files
committed
feat(loops): add a Loops tab to each context
1 parent 12a424b commit b6e85c8

4 files changed

Lines changed: 168 additions & 1 deletion

File tree

packages/ui/src/features/canvas/channelSections.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,12 @@
55
* `channelSection`; `label` is the tab + nav text.
66
*/
77
export interface ChannelSection {
8-
key: "artifacts" | "history" | "context";
8+
key: "loops" | "artifacts" | "history" | "context";
99
label: string;
1010
}
1111

1212
export const CHANNEL_SECTIONS: readonly ChannelSection[] = [
13+
{ key: "loops", label: "Loops" },
1314
{ key: "artifacts", label: "Artifacts" },
1415
{ key: "history", label: "Recents" },
1516
{ key: "context", label: "CONTEXT.md" },
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { PlusIcon, RepeatIcon } from "@phosphor-icons/react";
2+
import { Button } from "@posthog/ui/primitives/Button";
3+
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
4+
import { Flex, Heading, Text } from "@radix-ui/themes";
5+
import { useMemo } from "react";
6+
import { LoopRow } from "../../loops/components/LoopRow";
7+
import { useLoops } from "../../loops/hooks/useLoops";
8+
import { useLoopDraftStore } from "../../loops/loopDraftStore";
9+
import { defaultLoopContextOutputs } from "../../loops/loopFormTypes";
10+
import { useChannels } from "../hooks/useChannels";
11+
12+
/** The "Loops" tab of a context: the automations attached to this context, and a shortcut
13+
* to create a new one already attached to it. `channelId` is the desktop folder id, which
14+
* matches a loop's `context_target.folder_id`. */
15+
export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
16+
const { data: loops, isLoading, isError } = useLoops();
17+
const { channels } = useChannels();
18+
const channel = channels.find((c) => c.id === channelId);
19+
20+
const attachedLoops = useMemo(
21+
() =>
22+
(loops ?? []).filter(
23+
(loop) => loop.context_target?.folder_id === channelId,
24+
),
25+
[loops, channelId],
26+
);
27+
28+
const createLoopForContext = () => {
29+
useLoopDraftStore.getState().setPrefill({
30+
contextTarget: {
31+
folderId: channelId,
32+
name: channel?.name ?? channelId,
33+
outputs: defaultLoopContextOutputs(),
34+
},
35+
});
36+
navigateToNewLoop();
37+
};
38+
39+
return (
40+
<Flex
41+
direction="column"
42+
gap="5"
43+
className="mx-auto w-full max-w-4xl px-8 py-8"
44+
>
45+
<Flex align="center" justify="between" gap="3">
46+
<Flex direction="column" gap="1" className="min-w-0">
47+
<Heading className="font-bold text-xl">Loops</Heading>
48+
<Text color="gray" className="text-sm">
49+
Automations attached to this context. They can post runs to its feed
50+
and keep its context.md or a canvas up to date.
51+
</Text>
52+
</Flex>
53+
<Button variant="solid" size="2" onClick={createLoopForContext}>
54+
<PlusIcon size={14} />
55+
New loop
56+
</Button>
57+
</Flex>
58+
59+
{isLoading ? (
60+
<LoopsSkeleton />
61+
) : isError ? (
62+
<EmptyNotice
63+
title="Couldn't load loops"
64+
hint="The loops API returned an error. Try again in a moment."
65+
/>
66+
) : attachedLoops.length > 0 ? (
67+
<Flex direction="column" gap="2">
68+
{attachedLoops.map((loop) => (
69+
<LoopRow key={loop.id} loop={loop} />
70+
))}
71+
</Flex>
72+
) : (
73+
<EmptyNotice
74+
icon={<RepeatIcon size={16} />}
75+
title="No loops attached yet"
76+
hint="Attach a loop to this context to post its runs here and keep its context.md or a canvas up to date."
77+
/>
78+
)}
79+
</Flex>
80+
);
81+
}
82+
83+
function EmptyNotice({
84+
icon,
85+
title,
86+
hint,
87+
}: {
88+
icon?: React.ReactNode;
89+
title: string;
90+
hint: string;
91+
}) {
92+
return (
93+
<Flex
94+
align="center"
95+
justify="center"
96+
direction="column"
97+
gap="1"
98+
py="7"
99+
className="rounded border border-gray-6 border-dashed"
100+
>
101+
{icon ? (
102+
<Flex
103+
align="center"
104+
justify="center"
105+
className="mb-1 size-8 rounded-(--radius-2) bg-(--gray-3) text-gray-11"
106+
>
107+
{icon}
108+
</Flex>
109+
) : null}
110+
<Text className="font-medium text-sm">{title}</Text>
111+
<Text color="gray" className="max-w-[420px] text-center text-[13px]">
112+
{hint}
113+
</Text>
114+
</Flex>
115+
);
116+
}
117+
118+
function LoopsSkeleton() {
119+
return (
120+
<Flex direction="column" gap="2">
121+
{[0, 1, 2].map((i) => (
122+
<div
123+
key={i}
124+
className="h-[68px] animate-pulse rounded-(--radius-2) border border-border bg-(--gray-2)"
125+
/>
126+
))}
127+
</Flex>
128+
);
129+
}

packages/ui/src/router/routeTree.gen.ts

Lines changed: 21 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { WebsiteChannelLoops } from "@posthog/ui/features/canvas/components/WebsiteChannelLoops";
2+
import {
3+
ChannelSkeleton,
4+
withRouteSkeleton,
5+
} from "@posthog/ui/router/routeSkeletons";
6+
import { createFileRoute } from "@tanstack/react-router";
7+
8+
export const Route = createFileRoute("/website/$channelId/loops")({
9+
component: ChannelLoopsRoute,
10+
...withRouteSkeleton(ChannelSkeleton),
11+
});
12+
13+
function ChannelLoopsRoute() {
14+
const { channelId } = Route.useParams();
15+
return <WebsiteChannelLoops channelId={channelId} />;
16+
}

0 commit comments

Comments
 (0)