Skip to content

Commit aebc65a

Browse files
committed
feat(loops): attach a loop to a context in the create form
1 parent c42748e commit aebc65a

4 files changed

Lines changed: 243 additions & 0 deletions

File tree

packages/api-client/src/loops.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,33 @@ export namespace LoopSchemas {
8989
slack: LoopNotificationChannelWrite;
9090
}>;
9191

92+
/** What a context-attached loop maintains each run. */
93+
export type LoopContextOutputs = {
94+
/** File each run into the context's feed as a card. */
95+
post_to_feed: boolean;
96+
/** Read and republish the context's context.md each run. */
97+
update_context: boolean;
98+
/** Id of a canvas in this context to keep up to date, or null. */
99+
canvas_id: string | null;
100+
};
101+
102+
export type LoopContextOutputsWrite = Partial<LoopContextOutputs>;
103+
104+
/** The context (a "#channel" / desktop folder) a loop is attached to, plus what it maintains. */
105+
export type LoopContextTarget = {
106+
/** Desktop folder id of the attached context. */
107+
folder_id: string;
108+
/** Context (channel) name, used to file runs into its feed. */
109+
name: string;
110+
outputs: LoopContextOutputs;
111+
};
112+
113+
export type LoopContextTargetWrite = {
114+
folder_id: string;
115+
name: string;
116+
outputs?: LoopContextOutputsWrite;
117+
};
118+
92119
export type LoopScheduleTriggerConfig = {
93120
cron_expression?: string;
94121
timezone?: string;
@@ -155,6 +182,8 @@ export namespace LoopSchemas {
155182
behaviors: LoopBehaviors;
156183
connectors: LoopConnectors;
157184
notifications: LoopNotifications;
185+
/** Context this loop is attached to, or null when unattached. */
186+
context_target: LoopContextTarget | null;
158187
/** Backend-set: internal loops are hidden from the UI (never returned by the
159188
* list/detail API), so this is effectively always false for loops a client can see. */
160189
internal: boolean;
@@ -189,6 +218,8 @@ export namespace LoopSchemas {
189218
behaviors?: LoopBehaviorsWrite;
190219
connectors?: LoopConnectorsWrite;
191220
notifications?: LoopNotificationsWrite;
221+
/** Context to attach this loop to, or null to detach. */
222+
context_target?: LoopContextTargetWrite | null;
192223
triggers?: Array<LoopTriggerWrite>;
193224
};
194225

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
import type { LoopSchemas } from "@posthog/api-client/loops";
2+
import { Switch } from "@posthog/quill";
3+
import { SettingsOptionSelect } from "@posthog/ui/features/settings/SettingsOptionSelect";
4+
import { Flex, Text } from "@radix-ui/themes";
5+
import { useChannels } from "../../canvas/hooks/useChannels";
6+
import { useDashboards } from "../../canvas/hooks/useDashboards";
7+
import {
8+
defaultLoopContextOutputs,
9+
type LoopContextTargetDraft,
10+
} from "../loopFormTypes";
11+
12+
const NOT_ATTACHED_VALUE = "__none__";
13+
14+
interface LoopContextFieldsProps {
15+
value: LoopContextTargetDraft | null;
16+
onChange: (value: LoopContextTargetDraft | null) => void;
17+
disabled?: boolean;
18+
}
19+
20+
export function LoopContextFields({
21+
value,
22+
onChange,
23+
disabled,
24+
}: LoopContextFieldsProps) {
25+
const { channels } = useChannels();
26+
const { dashboards } = useDashboards(value?.folderId);
27+
const hasCanvases = dashboards.length > 0;
28+
29+
const selectContext = (folderId: string) => {
30+
if (folderId === NOT_ATTACHED_VALUE) {
31+
onChange(null);
32+
return;
33+
}
34+
const channel = channels.find((c) => c.id === folderId);
35+
if (!channel) return;
36+
// Carry the output toggles across contexts, but drop a canvas that belonged to the old one.
37+
onChange({
38+
folderId: channel.id,
39+
name: channel.name,
40+
outputs: {
41+
...(value?.outputs ?? defaultLoopContextOutputs()),
42+
canvas_id: null,
43+
},
44+
});
45+
};
46+
47+
const patchOutputs = (outputs: Partial<LoopSchemas.LoopContextOutputs>) => {
48+
if (!value) return;
49+
onChange({ ...value, outputs: { ...value.outputs, ...outputs } });
50+
};
51+
52+
const contextOptions = [
53+
{ value: NOT_ATTACHED_VALUE, label: "Not attached" },
54+
...channels.map((channel) => ({
55+
value: channel.id,
56+
label: `#${channel.name}`,
57+
})),
58+
];
59+
60+
return (
61+
<Flex direction="column" gap="3">
62+
<SettingsOptionSelect
63+
value={value?.folderId ?? NOT_ATTACHED_VALUE}
64+
options={contextOptions}
65+
disabled={disabled}
66+
ariaLabel="Context"
67+
onValueChange={selectContext}
68+
/>
69+
70+
{value ? (
71+
<Flex
72+
direction="column"
73+
gap="3"
74+
className="rounded-(--radius-2) border border-border bg-(--color-panel-solid) p-3"
75+
>
76+
<ToggleRow
77+
title="Show runs in the feed"
78+
description="Each run appears as a card in this context's feed."
79+
checked={value.outputs.post_to_feed}
80+
disabled={disabled}
81+
onChange={(checked) => patchOutputs({ post_to_feed: checked })}
82+
/>
83+
<ToggleRow
84+
title="Keep context.md updated"
85+
description="Each run reads this context's context.md and republishes it with the latest state."
86+
checked={value.outputs.update_context}
87+
disabled={disabled}
88+
onChange={(checked) => patchOutputs({ update_context: checked })}
89+
/>
90+
<ToggleRow
91+
title="Maintain a canvas"
92+
description={
93+
hasCanvases
94+
? "Each run rewrites a canvas in this context with fresh content."
95+
: "This context has no canvases yet. Create one to keep it up to date here."
96+
}
97+
checked={!!value.outputs.canvas_id}
98+
disabled={disabled || !hasCanvases}
99+
onChange={(checked) =>
100+
patchOutputs({
101+
canvas_id: checked ? (dashboards[0]?.id ?? null) : null,
102+
})
103+
}
104+
/>
105+
{value.outputs.canvas_id ? (
106+
<SettingsOptionSelect
107+
value={value.outputs.canvas_id}
108+
options={dashboards.map((dashboard) => ({
109+
value: dashboard.id,
110+
label: dashboard.name,
111+
}))}
112+
disabled={disabled}
113+
ariaLabel="Canvas"
114+
onValueChange={(canvasId) =>
115+
patchOutputs({ canvas_id: canvasId })
116+
}
117+
/>
118+
) : null}
119+
</Flex>
120+
) : null}
121+
</Flex>
122+
);
123+
}
124+
125+
function ToggleRow({
126+
title,
127+
description,
128+
checked,
129+
onChange,
130+
disabled,
131+
}: {
132+
title: string;
133+
description: string;
134+
checked: boolean;
135+
onChange: (checked: boolean) => void;
136+
disabled?: boolean;
137+
}) {
138+
return (
139+
<Flex align="center" justify="between" gap="3">
140+
<Flex direction="column" gap="0" className="min-w-0">
141+
<Text className="font-medium text-[13px] text-gray-12">{title}</Text>
142+
<Text className="text-[12px] text-gray-10">{description}</Text>
143+
</Flex>
144+
<Switch
145+
checked={checked}
146+
disabled={disabled}
147+
aria-label={title}
148+
onCheckedChange={onChange}
149+
/>
150+
</Flex>
151+
);
152+
}

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,12 @@ import {
2424
isAutoFixEnabled,
2525
isLoopFormValid,
2626
isTriggerDraftValid,
27+
type LoopContextTargetDraft,
2728
type LoopFormValues,
2829
loopToFormValues,
2930
} from "../loopFormTypes";
3031
import { LoopBehaviorFields } from "./LoopBehaviorFields";
32+
import { LoopContextFields } from "./LoopContextFields";
3133
import { Field } from "./LoopFormPrimitives";
3234
import { LoopModelFields } from "./LoopModelFields";
3335
import { LoopNotificationsFields } from "./LoopNotificationsFields";
@@ -214,6 +216,19 @@ export function LoopForm({ loop }: LoopFormProps) {
214216

215217
<Divider />
216218

219+
<Field
220+
label="Context"
221+
hint="Attach this loop to a context to file its runs in the feed and keep its context.md or a canvas up to date."
222+
>
223+
<LoopContextFields
224+
value={values.contextTarget}
225+
disabled={isSubmitting}
226+
onChange={(contextTarget) => patch({ contextTarget })}
227+
/>
228+
</Field>
229+
230+
<Divider />
231+
217232
<Field
218233
label="Repository"
219234
hint={
@@ -477,6 +492,10 @@ function ReviewList({ values }: { values: LoopFormValues }) {
477492
values.model || "Default model"
478493
} · ${reasoning} reasoning`}
479494
/>
495+
<ReviewRow
496+
label="Context"
497+
value={describeContext(values.contextTarget)}
498+
/>
480499
<ReviewRow
481500
label="Repository"
482501
value={
@@ -528,6 +547,17 @@ function ReviewRow({
528547
);
529548
}
530549

550+
function describeContext(target: LoopContextTargetDraft | null): string {
551+
if (!target) return "Not attached";
552+
const outputs: string[] = [];
553+
if (target.outputs.post_to_feed) outputs.push("feed");
554+
if (target.outputs.update_context) outputs.push("context.md");
555+
if (target.outputs.canvas_id) outputs.push("canvas");
556+
return outputs.length > 0
557+
? `#${target.name} (${outputs.join(", ")})`
558+
: `#${target.name}`;
559+
}
560+
531561
function describeTrigger(trigger: LoopFormValues["triggers"][number]): string {
532562
if (trigger.type === "schedule") {
533563
const config = trigger.config as LoopSchemas.LoopScheduleTriggerConfig;

packages/ui/src/features/loops/loopFormTypes.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ export interface LoopTriggerDraft {
1616
config: LoopSchemas.LoopTriggerConfig;
1717
}
1818

19+
/** The context a loop is attached to in the form. `null` on `LoopFormValues.contextTarget`
20+
* means the loop isn't attached to any context. */
21+
export interface LoopContextTargetDraft {
22+
folderId: string;
23+
name: string;
24+
outputs: LoopSchemas.LoopContextOutputs;
25+
}
26+
1927
export interface LoopFormValues {
2028
name: string;
2129
description: string;
@@ -33,6 +41,7 @@ export interface LoopFormValues {
3341
triggers: LoopTriggerDraft[];
3442
behaviors: LoopSchemas.LoopBehaviors;
3543
notifications: LoopSchemas.LoopNotifications;
44+
contextTarget: LoopContextTargetDraft | null;
3645
}
3746

3847
export function emptyLoopScheduleTriggerConfig(): LoopSchemas.LoopScheduleTriggerConfig {
@@ -61,6 +70,12 @@ export function defaultLoopBehaviors(): LoopSchemas.LoopBehaviors {
6170
};
6271
}
6372

73+
/** Sensible defaults when a loop is first attached to a context: file its runs into the
74+
* feed, but don't touch context.md or a canvas until the user opts in. */
75+
export function defaultLoopContextOutputs(): LoopSchemas.LoopContextOutputs {
76+
return { post_to_feed: true, update_context: false, canvas_id: null };
77+
}
78+
6479
/** The single "Auto-fix pull requests" toggle drives both CI-watching and
6580
* review-comment fixing; it reads as on only when both are on. */
6681
export function isAutoFixEnabled(
@@ -96,6 +111,7 @@ export function emptyLoopFormValues(): LoopFormValues {
96111
triggers: [],
97112
behaviors: defaultLoopBehaviors(),
98113
notifications: defaultLoopNotifications(),
114+
contextTarget: null,
99115
};
100116
}
101117

@@ -118,6 +134,13 @@ export function loopToFormValues(loop: LoopSchemas.Loop): LoopFormValues {
118134
})),
119135
behaviors: loop.behaviors,
120136
notifications: loop.notifications,
137+
contextTarget: loop.context_target
138+
? {
139+
folderId: loop.context_target.folder_id,
140+
name: loop.context_target.name,
141+
outputs: loop.context_target.outputs,
142+
}
143+
: null,
121144
};
122145
}
123146

@@ -141,6 +164,13 @@ export function formValuesToLoopWrite(
141164
})),
142165
behaviors: values.behaviors,
143166
notifications: values.notifications,
167+
context_target: values.contextTarget
168+
? {
169+
folder_id: values.contextTarget.folderId,
170+
name: values.contextTarget.name,
171+
outputs: values.contextTarget.outputs,
172+
}
173+
: null,
144174
};
145175
}
146176

0 commit comments

Comments
 (0)