Skip to content

Commit d55a0f8

Browse files
committed
gate loop creation at the project cap
1 parent 2e53459 commit d55a0f8

5 files changed

Lines changed: 94 additions & 21 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,13 @@ export namespace LoopSchemas {
239239
next: string | null;
240240
previous: string | null;
241241
results: Array<Loop>;
242+
/** Hard cap on non-deleted loops per project. Read this rather than hardcoding a
243+
* number: the backend is authoritative, so raising it there reflects here on the
244+
* next list. Creating beyond the cap returns a 429 `loop_safety_limit`. */
245+
max_loops_per_team: number;
246+
/** Current non-deleted, user-facing loops in this project, counted against
247+
* `max_loops_per_team`. At or above the cap, creation is blocked. */
248+
total_loop_count: number;
242249
};
243250

244251
export type LoopRun = {

packages/ui/src/features/canvas/components/WebsiteChannelLoops.tsx

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
} from "../../loops/components/LoopFallbacks";
1313
import { LoopRow } from "../../loops/components/LoopRow";
1414
import { LoopsEmptyState } from "../../loops/components/LoopsEmptyState";
15-
import { useLoops } from "../../loops/hooks/useLoops";
15+
import { useLoopLimits, useLoops } from "../../loops/hooks/useLoops";
1616
import { useLoopDraftStore } from "../../loops/loopDraftStore";
1717
import { defaultLoopContextOutputs } from "../../loops/loopFormTypes";
1818
import { useChannels } from "../hooks/useChannels";
@@ -43,6 +43,11 @@ function contextQuickStarts(name: string): { label: string; prompt: string }[] {
4343
* this context. `channelId` is the desktop folder id, matching `context_target.folder_id`. */
4444
export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
4545
const { data: loops, isLoading, isError } = useLoops();
46+
const limits = useLoopLimits();
47+
const limitReason =
48+
limits?.atLimit === true
49+
? `You've reached the limit of ${limits.max} loops for this project. Delete one to add another.`
50+
: null;
4651
const { channels } = useChannels();
4752
const channel = channels.find((c) => c.id === channelId);
4853
const contextName = channel?.name ?? channelId;
@@ -103,7 +108,14 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
103108
keeps its context.md or a canvas up to date.
104109
</Text>
105110
</Flex>
106-
<Button variant="soft" color="gray" size="2" onClick={startBlank}>
111+
<Button
112+
variant="soft"
113+
color="gray"
114+
size="2"
115+
onClick={startBlank}
116+
disabled={limitReason != null}
117+
disabledReason={limitReason}
118+
>
107119
<PlusIcon size={14} />
108120
Create manually
109121
</Button>
@@ -143,6 +155,7 @@ export function WebsiteChannelLoops({ channelId }: { channelId: string }) {
143155
context={{ folderId: channelId, name: contextName }}
144156
placeholder={`What should #${contextName} keep an eye on?`}
145157
quickStarts={contextQuickStarts(contextName)}
158+
disabledReason={limitReason}
146159
/>
147160
</Flex>
148161
</div>

packages/ui/src/features/loops/components/LoopBuilderComposer.tsx

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,26 +11,30 @@ const DEFAULT_EXAMPLES = [
1111

1212
/** The "describe what you want and an agent builds it" prompt box. Passing `context` attaches
1313
* the built loop to it. `quickStarts` replaces the generic example chips above the box with
14-
* labeled starters (each fills the box for the user to finish). */
14+
* labeled starters (each fills the box for the user to finish). `disabledReason`, when set, blocks
15+
* the whole composer and shows that reason instead of the helper text (e.g. the project loop cap). */
1516
export function LoopBuilderComposer({
1617
context,
1718
placeholder = "What do you want automated?",
1819
quickStarts,
20+
disabledReason,
1921
}: {
2022
context?: { folderId: string; name: string };
2123
placeholder?: string;
2224
quickStarts?: { label: string; prompt: string }[];
25+
disabledReason?: string | null;
2326
}) {
2427
const [prompt, setPrompt] = useState("");
2528
const { runTask, isRunning } = useLoopBuilderTask(context);
2629

30+
const blocked = Boolean(disabledReason);
2731
const chips =
2832
quickStarts ??
2933
DEFAULT_EXAMPLES.map((example) => ({ label: example, prompt: example }));
3034

3135
const start = () => {
3236
const text = prompt.trim();
33-
if (!text || isRunning) return;
37+
if (!text || isRunning || blocked) return;
3438
void runTask(text);
3539
};
3640

@@ -41,7 +45,7 @@ export function LoopBuilderComposer({
4145
<button
4246
key={chip.label}
4347
type="button"
44-
disabled={isRunning}
48+
disabled={isRunning || blocked}
4549
onClick={() => setPrompt(chip.prompt)}
4650
className="rounded-full border border-gray-5 bg-gray-2 px-3 py-1 text-gray-11 text-xs transition-colors hover:border-gray-7 hover:bg-gray-3 disabled:opacity-60"
4751
>
@@ -57,7 +61,7 @@ export function LoopBuilderComposer({
5761
<textarea
5862
value={prompt}
5963
rows={2}
60-
disabled={isRunning}
64+
disabled={isRunning || blocked}
6165
placeholder={placeholder}
6266
className="w-full resize-none bg-transparent text-[13px] text-gray-12 leading-relaxed outline-none placeholder:text-gray-9 disabled:opacity-60"
6367
onChange={(e) => setPrompt(e.target.value)}
@@ -73,16 +77,18 @@ export function LoopBuilderComposer({
7377
}}
7478
/>
7579
<Flex align="center" justify="between" gap="3">
76-
<Text className="text-[11px] text-gray-9">
77-
An agent builds the loop with you, then creates it on your
78-
confirmation
80+
<Text
81+
className={`text-[11px] ${blocked ? "text-(--amber-11)" : "text-gray-9"}`}
82+
>
83+
{disabledReason ??
84+
"An agent builds the loop with you, then creates it on your confirmation"}
7985
</Text>
8086
<IconButton
8187
variant="solid"
8288
size="1"
8389
aria-label="Build loop with an agent"
8490
loading={isRunning}
85-
disabled={!prompt.trim() || isRunning}
91+
disabled={!prompt.trim() || isRunning || blocked}
8692
onClick={start}
8793
>
8894
<ArrowUpIcon size={13} weight="bold" />

packages/ui/src/features/loops/components/LoopsListView.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { Button } from "@posthog/ui/primitives/Button";
44
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
55
import { Flex, Heading, Text } from "@radix-ui/themes";
66
import { useMemo } from "react";
7-
import { useLoops } from "../hooks/useLoops";
7+
import { useLoopLimits, useLoops } from "../hooks/useLoops";
88
import { useLoopDraftStore } from "../loopDraftStore";
99
import type { LoopTemplate } from "../loopTemplates";
1010
import { LoopBuilderComposer } from "./LoopBuilderComposer";
@@ -13,8 +13,17 @@ import { LoopRow } from "./LoopRow";
1313
import { LoopsEmptyState } from "./LoopsEmptyState";
1414
import { LoopTemplatesSection } from "./LoopTemplatesSection";
1515

16+
/** Copy shown when the project is at its loop cap. `max` comes from the backend so the number
17+
* never drifts from the limit the server actually enforces. */
18+
function loopLimitReason(max: number): string {
19+
return `You've reached the limit of ${max} loops for this project. Delete one to add another.`;
20+
}
21+
1622
export function LoopsListView() {
1723
const { data: loops, isLoading, isError, error } = useLoops();
24+
const limits = useLoopLimits();
25+
const limitReason =
26+
limits?.atLimit === true ? loopLimitReason(limits.max) : null;
1827

1928
const headerContent = useMemo(
2029
() => (
@@ -78,7 +87,14 @@ export function LoopsListView() {
7887
the laptop!
7988
</Text>
8089
</Flex>
81-
<Button variant="soft" color="gray" size="2" onClick={startBlank}>
90+
<Button
91+
variant="soft"
92+
color="gray"
93+
size="2"
94+
onClick={startBlank}
95+
disabled={limitReason != null}
96+
disabledReason={limitReason}
97+
>
8298
<PlusIcon size={14} />
8399
Create manually
84100
</Button>
@@ -120,7 +136,7 @@ export function LoopsListView() {
120136
gap="2"
121137
className="mx-auto w-full max-w-5xl px-8 pb-6"
122138
>
123-
<LoopBuilderComposer />
139+
<LoopBuilderComposer disabledReason={limitReason} />
124140
</Flex>
125141
</div>
126142
</Flex>

packages/ui/src/features/loops/hooks/useLoops.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,24 +2,55 @@ import { type LoopSchemas, listLoops } from "@posthog/api-client/loops";
22
import { AUTH_SCOPED_QUERY_META } from "@posthog/ui/features/auth/useCurrentUser";
33
import { useQuery } from "@tanstack/react-query";
44
import { loopsKeys } from "./loopsKeys";
5-
import { useLoopsClient } from "./useLoopsClient";
5+
import { type LoopsApiClient, useLoopsClient } from "./useLoopsClient";
66

77
const LOOPS_LIST_LIMIT = 100;
88

9-
export function useLoops() {
10-
const loopsClient = useLoopsClient();
11-
12-
return useQuery<LoopSchemas.Loop[]>({
9+
/** Shared query for the loops list page. Both `useLoops` (the loop rows) and `useLoopLimits`
10+
* (the per-project cap) read from this single fetch via `select`, so the cap the backend serves
11+
* stays in lockstep with the list and there's no second request. */
12+
function loopsPageQueryOptions(loopsClient: LoopsApiClient | null) {
13+
return {
1314
queryKey: loopsKeys.list(loopsClient?.projectId ?? null),
14-
queryFn: async () => {
15+
queryFn: async (): Promise<LoopSchemas.PaginatedLoopList> => {
1516
if (!loopsClient) throw new Error("Not authenticated");
16-
const page = await listLoops(loopsClient.client, loopsClient.projectId, {
17+
return listLoops(loopsClient.client, loopsClient.projectId, {
1718
limit: LOOPS_LIST_LIMIT,
1819
});
19-
return page.results;
2020
},
2121
enabled: !!loopsClient,
2222
staleTime: 30_000,
2323
meta: AUTH_SCOPED_QUERY_META,
24+
};
25+
}
26+
27+
export function useLoops() {
28+
const loopsClient = useLoopsClient();
29+
return useQuery({
30+
...loopsPageQueryOptions(loopsClient),
31+
select: (page: LoopSchemas.PaginatedLoopList) => page.results,
32+
});
33+
}
34+
35+
/** The per-project loop cap, straight from the backend so the frontend never hardcodes it. */
36+
export interface LoopLimits {
37+
/** Hard cap on non-deleted loops in this project. */
38+
max: number;
39+
/** Current non-deleted loops counted against `max`. */
40+
used: number;
41+
/** True when creating another loop would be rejected with a 429. */
42+
atLimit: boolean;
43+
}
44+
45+
export function useLoopLimits(): LoopLimits | null {
46+
const loopsClient = useLoopsClient();
47+
const { data } = useQuery({
48+
...loopsPageQueryOptions(loopsClient),
49+
select: (page: LoopSchemas.PaginatedLoopList): LoopLimits => ({
50+
max: page.max_loops_per_team,
51+
used: page.total_loop_count,
52+
atLimit: page.total_loop_count >= page.max_loops_per_team,
53+
}),
2454
});
55+
return data ?? null;
2556
}

0 commit comments

Comments
 (0)