Skip to content

Commit 0bf2a9e

Browse files
committed
make loop builder sessions resumable
1 parent 0092213 commit 0bf2a9e

7 files changed

Lines changed: 326 additions & 3 deletions

File tree

packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
defaultEligibleModel,
1717
getCloudUrlFromRegion,
1818
} from "@posthog/shared";
19+
import type { Task } from "@posthog/shared/domain-types";
1920
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
2021
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
2122
import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel";
@@ -90,6 +91,8 @@ export interface UseInboxCloudTaskRunnerOptions {
9091
buildInput: (ctx: InboxCloudTaskInputContext) => TaskCreationInput;
9192
/** Telemetry extras merged into the TASK_CREATED event when the run succeeds. */
9293
analyticsExtras?: Record<string, unknown>;
94+
/** Called with the created task record, before any navigation happens. */
95+
onTaskCreated?: (task: Task) => void;
9396
/**
9497
* When false, the runner does not navigate to the created task. The task is
9598
* still added to the sidebar via `invalidateTasks`, and a success toast with a
@@ -120,6 +123,7 @@ export function useInboxCloudTaskRunner({
120123
loggerScope,
121124
buildInput,
122125
analyticsExtras,
126+
onTaskCreated,
123127
redirectOnSuccess = true,
124128
}: UseInboxCloudTaskRunnerOptions): UseInboxCloudTaskRunnerReturn {
125129
const [isRunning, setIsRunning] = useState(false);
@@ -225,6 +229,7 @@ export function useInboxCloudTaskRunner({
225229
const result = await taskService.createTask(input, (output) => {
226230
createdTask = output.task;
227231
invalidateTasks(output.task);
232+
onTaskCreated?.(output.task);
228233
if (redirectOnSuccess) {
229234
void openTask(output.task);
230235
}
@@ -299,6 +304,7 @@ export function useInboxCloudTaskRunner({
299304
buildInput,
300305
copy,
301306
analyticsExtras,
307+
onTaskCreated,
302308
modelResolver,
303309
taskService,
304310
redirectOnSuccess,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,3 +173,22 @@ export const LongMixedList: Story = {
173173
}),
174174
},
175175
};
176+
177+
export const WithBuilderSessions: Story = {
178+
args: {
179+
builderSessions: [
180+
{
181+
taskId: "builder-task-1",
182+
prompt: "Summarize my open PRs every weekday morning",
183+
startedAt: 1752000000000,
184+
},
185+
{
186+
taskId: "builder-task-2",
187+
prompt: "Build a loop",
188+
startedAt: 1752000600000,
189+
},
190+
],
191+
onResumeBuilderSession: () => {},
192+
onBuilderSessionStopped: () => {},
193+
},
194+
};

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

Lines changed: 101 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,28 @@
1-
import { CloudIcon, PlusIcon, RepeatIcon } from "@phosphor-icons/react";
1+
import {
2+
ChatCircleDotsIcon,
3+
CloudIcon,
4+
PlusIcon,
5+
RepeatIcon,
6+
StopIcon,
7+
} from "@phosphor-icons/react";
28
import type { LoopSchemas } from "@posthog/api-client/loops";
39
import type { UserBasic } from "@posthog/shared/domain-types";
410
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
11+
import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog";
512
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
613
import { Button } from "@posthog/ui/primitives/Button";
7-
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
14+
import {
15+
navigateToNewLoop,
16+
navigateToTaskDetail,
17+
} from "@posthog/ui/router/navigationBridge";
818
import { Flex, Heading, Text } from "@radix-ui/themes";
919
import { useMemo, useState } from "react";
20+
import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions";
1021
import { useLoopLimits, useLoops } from "../hooks/useLoops";
22+
import {
23+
type LoopBuilderSession,
24+
useLoopBuilderSessionStore,
25+
} from "../loopBuilderSessionStore";
1126
import { useLoopDraftStore } from "../loopDraftStore";
1227
import type { LoopTemplate } from "../loopTemplates";
1328
import { LoopBuilderComposer } from "./LoopBuilderComposer";
@@ -23,6 +38,7 @@ function loopLimitReason(max: number): string {
2338
}
2439

2540
const EMPTY_MEMBERS: UserBasic[] = [];
41+
const EMPTY_BUILDER_SESSIONS: LoopBuilderSession[] = [];
2642

2743
const SECTION_PREVIEW_COUNT = 5;
2844

@@ -31,6 +47,14 @@ function startBlankLoop(): void {
3147
navigateToNewLoop();
3248
}
3349

50+
function resumeBuilderSession(taskId: string): void {
51+
navigateToTaskDetail(taskId);
52+
}
53+
54+
function removeBuilderSession(taskId: string): void {
55+
useLoopBuilderSessionStore.getState().removeSession(taskId);
56+
}
57+
3458
function startLoopFromTemplate(template: LoopTemplate): void {
3559
useLoopDraftStore
3660
.getState()
@@ -60,6 +84,8 @@ export function LoopsListView() {
6084
);
6185
useSetHeaderContent(headerContent);
6286

87+
const builderSessions = useLoopBuilderSessions();
88+
6389
const allLoops = loops ?? [];
6490
const teamLoops = allLoops.filter((loop) => loop.visibility === "team");
6591
const {
@@ -79,8 +105,11 @@ export function LoopsListView() {
79105
membersLoading={membersLoading}
80106
membersError={membersError}
81107
membersComplete={membersComplete}
108+
builderSessions={builderSessions}
82109
onStartBlank={startBlankLoop}
83110
onStartFromTemplate={startLoopFromTemplate}
111+
onResumeBuilderSession={resumeBuilderSession}
112+
onBuilderSessionStopped={removeBuilderSession}
84113
/>
85114
);
86115
}
@@ -94,8 +123,11 @@ interface LoopsListViewPresentationProps {
94123
membersLoading?: boolean;
95124
membersError?: boolean;
96125
membersComplete?: boolean;
126+
builderSessions?: LoopBuilderSession[];
97127
onStartBlank: () => void;
98128
onStartFromTemplate: (template: LoopTemplate) => void;
129+
onResumeBuilderSession?: (taskId: string) => void;
130+
onBuilderSessionStopped?: (taskId: string) => void;
99131
}
100132

101133
export function LoopsListViewPresentation({
@@ -107,8 +139,11 @@ export function LoopsListViewPresentation({
107139
membersLoading = false,
108140
membersError = false,
109141
membersComplete = true,
142+
builderSessions = EMPTY_BUILDER_SESSIONS,
110143
onStartBlank,
111144
onStartFromTemplate,
145+
onResumeBuilderSession,
146+
onBuilderSessionStopped,
112147
}: LoopsListViewPresentationProps) {
113148
const personalLoops = loops.filter((loop) => loop.visibility === "personal");
114149
const teamLoops = loops.filter((loop) => loop.visibility === "team");
@@ -199,13 +234,77 @@ export function LoopsListViewPresentation({
199234
gap="2"
200235
className="mx-auto w-full max-w-5xl px-8 pb-6"
201236
>
237+
{builderSessions.map((session) => (
238+
<BuilderSessionRow
239+
key={session.taskId}
240+
session={session}
241+
onResume={onResumeBuilderSession}
242+
onStopped={onBuilderSessionStopped}
243+
/>
244+
))}
202245
<LoopBuilderComposer disabledReason={limitReason} />
203246
</Flex>
204247
</div>
205248
</Flex>
206249
);
207250
}
208251

252+
function BuilderSessionRow({
253+
session,
254+
onResume,
255+
onStopped,
256+
}: {
257+
session: LoopBuilderSession;
258+
onResume?: (taskId: string) => void;
259+
onStopped?: (taskId: string) => void;
260+
}) {
261+
const [confirmStop, setConfirmStop] = useState(false);
262+
263+
return (
264+
<Flex
265+
align="center"
266+
gap="3"
267+
className="rounded-(--radius-3) border border-border bg-(--color-panel-solid) px-3 py-2"
268+
>
269+
<ChatCircleDotsIcon size={16} className="shrink-0 text-(--accent-11)" />
270+
<Flex direction="column" className="min-w-0 flex-1">
271+
<Text className="font-medium text-[11px] text-gray-9 uppercase tracking-wide">
272+
Builder in progress
273+
</Text>
274+
<Text className="truncate text-[13px] text-gray-12">
275+
{session.prompt}
276+
</Text>
277+
</Flex>
278+
<Button
279+
variant="soft"
280+
size="1"
281+
onClick={() => onResume?.(session.taskId)}
282+
>
283+
Resume
284+
</Button>
285+
<Button
286+
variant="ghost"
287+
color="gray"
288+
size="1"
289+
onClick={() => setConfirmStop(true)}
290+
>
291+
<StopIcon size={12} />
292+
Stop
293+
</Button>
294+
{confirmStop ? (
295+
<StopCloudRunDialog
296+
open={confirmStop}
297+
taskId={session.taskId}
298+
title="Stop loop builder"
299+
buttonLabel="Stop builder"
300+
onOpenChange={setConfirmStop}
301+
onStopped={() => onStopped?.(session.taskId)}
302+
/>
303+
) : null}
304+
</Flex>
305+
);
306+
}
307+
209308
function LoopListSection({
210309
title,
211310
loops,
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { isTerminalStatus } from "@posthog/shared/domain-types";
2+
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
3+
import { useTaskSummaries } from "@posthog/ui/features/tasks/useTasks";
4+
import { useEffect, useMemo } from "react";
5+
import {
6+
type LoopBuilderSession,
7+
useLoopBuilderSessionStore,
8+
} from "../loopBuilderSessionStore";
9+
10+
// A fresh task can briefly report no run (or a stale summary via
11+
// keepPreviousData) before the cloud run registers; don't treat that as ended.
12+
const FRESH_SESSION_GRACE_MS = 60_000;
13+
14+
/**
15+
* The recorded builder sessions whose cloud run is still alive. Sessions whose
16+
* sandbox has shut down (run completed, failed, cancelled, or task archived or
17+
* deleted) are pruned from the persisted store as their status comes in, so the
18+
* "in progress" list never offers a resume into a dead session.
19+
*/
20+
export function useLoopBuilderSessions(): LoopBuilderSession[] {
21+
const sessions = useLoopBuilderSessionStore((state) => state.sessions);
22+
const archivedTaskIds = useArchivedTaskIds();
23+
const taskIds = useMemo(
24+
() => sessions.map((session) => session.taskId),
25+
[sessions],
26+
);
27+
const {
28+
data: summaries,
29+
isSuccess,
30+
isPlaceholderData,
31+
} = useTaskSummaries(taskIds);
32+
33+
const liveTaskIds = useMemo(() => {
34+
if (!isSuccess || isPlaceholderData) return null;
35+
const live = new Set<string>();
36+
for (const summary of summaries ?? []) {
37+
const run = summary.latest_run;
38+
if (run?.environment === "cloud" && !isTerminalStatus(run.status)) {
39+
live.add(summary.id);
40+
}
41+
}
42+
return live;
43+
}, [isSuccess, isPlaceholderData, summaries]);
44+
45+
useEffect(() => {
46+
if (!liveTaskIds) return;
47+
const store = useLoopBuilderSessionStore.getState();
48+
for (const session of store.sessions) {
49+
const dead =
50+
!liveTaskIds.has(session.taskId) &&
51+
Date.now() - session.startedAt >= FRESH_SESSION_GRACE_MS;
52+
if (dead || archivedTaskIds.has(session.taskId)) {
53+
store.removeSession(session.taskId);
54+
}
55+
}
56+
}, [liveTaskIds, archivedTaskIds]);
57+
58+
return useMemo(
59+
() =>
60+
sessions.filter((session) => {
61+
if (archivedTaskIds.has(session.taskId)) return false;
62+
if (!liveTaskIds) return true;
63+
if (liveTaskIds.has(session.taskId)) return true;
64+
return Date.now() - session.startedAt < FRESH_SESSION_GRACE_MS;
65+
}),
66+
[sessions, archivedTaskIds, liveTaskIds],
67+
);
68+
}

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import type { TaskCreationInput } from "@posthog/core/task-detail/taskService";
2+
import type { Task } from "@posthog/shared/domain-types";
23
import {
34
type InboxCloudTaskInputContext,
45
useInboxCloudTaskRunner,
56
} from "@posthog/ui/features/inbox/hooks/useInboxCloudTaskRunner";
67
import { useCallback, useMemo, useRef } from "react";
78
import { buildLoopBuilderSystemInstructions } from "../loopBuilderPrompt";
9+
import { useLoopBuilderSessionStore } from "../loopBuilderSessionStore";
810

911
interface UseLoopBuilderTaskReturn {
1012
/** Start an auto-mode cloud session that builds a loop from `instructions` and navigate to it. */
@@ -40,7 +42,11 @@ export function useLoopBuilderTask(context?: {
4042
const taskContent = hasSeed ? userPrompt : "Build a loop";
4143
return {
4244
content: taskContent,
43-
taskDescription: taskContent,
45+
// Divergent on purpose: the description becomes the task's title, so
46+
// the sidebar row reads as the builder instead of the raw prompt.
47+
taskDescription: hasSeed
48+
? `Loop builder: ${userPrompt}`
49+
: "Loop builder",
4450
customInstructions: systemInstructions,
4551
// Building a loop is pure PostHog-MCP work (loops-list, integrations-list,
4652
// loops-create); it never touches a working tree. Run repo-less so the
@@ -70,6 +76,14 @@ export function useLoopBuilderTask(context?: {
7076
[],
7177
);
7278

79+
const handleTaskCreated = useCallback((task: Task) => {
80+
useLoopBuilderSessionStore.getState().addSession({
81+
taskId: task.id,
82+
prompt: instructionsRef.current.trim() || "Build a loop",
83+
startedAt: Date.now(),
84+
});
85+
}, []);
86+
7387
const { run, isRunning } = useInboxCloudTaskRunner({
7488
// The loop builder never needs a repo: run repo-less so the sandbox does no
7589
// clone and no GitHub identity is attached.
@@ -78,6 +92,7 @@ export function useLoopBuilderTask(context?: {
7892
loggerScope: "loop-builder",
7993
copy,
8094
buildInput,
95+
onTaskCreated: handleTaskCreated,
8196
});
8297

8398
const runTask = useCallback(

0 commit comments

Comments
 (0)