Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
defaultEligibleModel,
getCloudUrlFromRegion,
} from "@posthog/shared";
import type { Task } from "@posthog/shared/domain-types";
import { useAuthStateValue } from "@posthog/ui/features/auth/store";
import { showOfflineToast } from "@posthog/ui/features/connectivity/connectivityToast";
import { resolveDefaultModel } from "@posthog/ui/features/inbox/hooks/resolveDefaultModel";
Expand Down Expand Up @@ -90,6 +91,8 @@ export interface UseInboxCloudTaskRunnerOptions {
buildInput: (ctx: InboxCloudTaskInputContext) => TaskCreationInput;
/** Telemetry extras merged into the TASK_CREATED event when the run succeeds. */
analyticsExtras?: Record<string, unknown>;
/** Called with the created task record, before any navigation happens. */
onTaskCreated?: (task: Task) => void;
/**
* When false, the runner does not navigate to the created task. The task is
* still added to the sidebar via `invalidateTasks`, and a success toast with a
Expand Down Expand Up @@ -120,6 +123,7 @@ export function useInboxCloudTaskRunner({
loggerScope,
buildInput,
analyticsExtras,
onTaskCreated,
redirectOnSuccess = true,
}: UseInboxCloudTaskRunnerOptions): UseInboxCloudTaskRunnerReturn {
const [isRunning, setIsRunning] = useState(false);
Expand Down Expand Up @@ -225,6 +229,7 @@ export function useInboxCloudTaskRunner({
const result = await taskService.createTask(input, (output) => {
createdTask = output.task;
invalidateTasks(output.task);
onTaskCreated?.(output.task);
if (redirectOnSuccess) {
void openTask(output.task);
}
Expand Down Expand Up @@ -299,6 +304,7 @@ export function useInboxCloudTaskRunner({
buildInput,
copy,
analyticsExtras,
onTaskCreated,
modelResolver,
taskService,
redirectOnSuccess,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,22 @@ export const LongMixedList: Story = {
}),
},
};

export const WithBuilderSessions: Story = {
args: {
builderSessions: [
{
taskId: "builder-task-1",
prompt: "Summarize my open PRs every weekday morning",
startedAt: 1752000000000,
},
{
taskId: "builder-task-2",
prompt: "Build a loop",
startedAt: 1752000600000,
},
],
onResumeBuilderSession: () => {},
onBuilderSessionStopped: () => {},
},
};
103 changes: 101 additions & 2 deletions packages/ui/src/features/loops/components/LoopsListView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { CloudIcon, PlusIcon, RepeatIcon } from "@phosphor-icons/react";
import {
ChatCircleDotsIcon,
CloudIcon,
PlusIcon,
RepeatIcon,
StopIcon,
} from "@phosphor-icons/react";
import type { LoopSchemas } from "@posthog/api-client/loops";
import type { UserBasic } from "@posthog/shared/domain-types";
import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers";
import { StopCloudRunDialog } from "@posthog/ui/features/sessions/components/StopCloudRunDialog";
import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent";
import { Button } from "@posthog/ui/primitives/Button";
import { navigateToNewLoop } from "@posthog/ui/router/navigationBridge";
import {
navigateToNewLoop,
navigateToTaskDetail,
} from "@posthog/ui/router/navigationBridge";
import { Flex, Heading, Text } from "@radix-ui/themes";
import { useMemo, useState } from "react";
import { useLoopBuilderSessions } from "../hooks/useLoopBuilderSessions";
import { useLoopLimits, useLoops } from "../hooks/useLoops";
import {
type LoopBuilderSession,
useLoopBuilderSessionStore,
} from "../loopBuilderSessionStore";
import { useLoopDraftStore } from "../loopDraftStore";
import type { LoopTemplate } from "../loopTemplates";
import { LoopBuilderComposer } from "./LoopBuilderComposer";
Expand All @@ -23,6 +38,7 @@ function loopLimitReason(max: number): string {
}

const EMPTY_MEMBERS: UserBasic[] = [];
const EMPTY_BUILDER_SESSIONS: LoopBuilderSession[] = [];

const SECTION_PREVIEW_COUNT = 5;

Expand All @@ -31,6 +47,14 @@ function startBlankLoop(): void {
navigateToNewLoop();
}

function resumeBuilderSession(taskId: string): void {
navigateToTaskDetail(taskId);
}

function removeBuilderSession(taskId: string): void {
useLoopBuilderSessionStore.getState().removeSession(taskId);
}

function startLoopFromTemplate(template: LoopTemplate): void {
useLoopDraftStore
.getState()
Expand Down Expand Up @@ -60,6 +84,8 @@ export function LoopsListView() {
);
useSetHeaderContent(headerContent);

const builderSessions = useLoopBuilderSessions();

const allLoops = loops ?? [];
const teamLoops = allLoops.filter((loop) => loop.visibility === "team");
const {
Expand All @@ -79,8 +105,11 @@ export function LoopsListView() {
membersLoading={membersLoading}
membersError={membersError}
membersComplete={membersComplete}
builderSessions={builderSessions}
onStartBlank={startBlankLoop}
onStartFromTemplate={startLoopFromTemplate}
onResumeBuilderSession={resumeBuilderSession}
onBuilderSessionStopped={removeBuilderSession}
/>
);
}
Expand All @@ -94,8 +123,11 @@ interface LoopsListViewPresentationProps {
membersLoading?: boolean;
membersError?: boolean;
membersComplete?: boolean;
builderSessions?: LoopBuilderSession[];
onStartBlank: () => void;
onStartFromTemplate: (template: LoopTemplate) => void;
onResumeBuilderSession?: (taskId: string) => void;
onBuilderSessionStopped?: (taskId: string) => void;
}

export function LoopsListViewPresentation({
Expand All @@ -107,8 +139,11 @@ export function LoopsListViewPresentation({
membersLoading = false,
membersError = false,
membersComplete = true,
builderSessions = EMPTY_BUILDER_SESSIONS,
onStartBlank,
onStartFromTemplate,
onResumeBuilderSession,
onBuilderSessionStopped,
}: LoopsListViewPresentationProps) {
const personalLoops = loops.filter((loop) => loop.visibility === "personal");
const teamLoops = loops.filter((loop) => loop.visibility === "team");
Expand Down Expand Up @@ -199,13 +234,77 @@ export function LoopsListViewPresentation({
gap="2"
className="mx-auto w-full max-w-5xl px-8 pb-6"
>
{builderSessions.map((session) => (
<BuilderSessionRow
key={session.taskId}
session={session}
onResume={onResumeBuilderSession}
onStopped={onBuilderSessionStopped}
/>
))}
<LoopBuilderComposer disabledReason={limitReason} />
</Flex>
</div>
</Flex>
);
}

function BuilderSessionRow({
session,
onResume,
onStopped,
}: {
session: LoopBuilderSession;
onResume?: (taskId: string) => void;
onStopped?: (taskId: string) => void;
}) {
const [confirmStop, setConfirmStop] = useState(false);

return (
<Flex
align="center"
gap="3"
className="rounded-(--radius-3) border border-border bg-(--color-panel-solid) px-3 py-2"
>
<ChatCircleDotsIcon size={16} className="shrink-0 text-(--accent-11)" />
<Flex direction="column" className="min-w-0 flex-1">
<Text className="font-medium text-[11px] text-gray-9 uppercase tracking-wide">
Builder in progress
</Text>
<Text className="truncate text-[13px] text-gray-12">
{session.prompt}
</Text>
</Flex>
<Button
variant="soft"
size="1"
onClick={() => onResume?.(session.taskId)}
>
Resume
</Button>
<Button
variant="ghost"
color="gray"
size="1"
onClick={() => setConfirmStop(true)}
>
<StopIcon size={12} />
Stop
</Button>
{confirmStop ? (
<StopCloudRunDialog
open={confirmStop}
taskId={session.taskId}
title="Stop loop builder"
buttonLabel="Stop builder"
onOpenChange={setConfirmStop}
onStopped={() => onStopped?.(session.taskId)}
/>
) : null}
</Flex>
);
}

function LoopListSection({
title,
loops,
Expand Down
68 changes: 68 additions & 0 deletions packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { isTerminalStatus } from "@posthog/shared/domain-types";
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
import { useTaskSummaries } from "@posthog/ui/features/tasks/useTasks";
import { useEffect, useMemo } from "react";
import {
type LoopBuilderSession,
useLoopBuilderSessionStore,
} from "../loopBuilderSessionStore";

// A fresh task can briefly report no run (or a stale summary via
// keepPreviousData) before the cloud run registers; don't treat that as ended.
const FRESH_SESSION_GRACE_MS = 60_000;

/**
* The recorded builder sessions whose cloud run is still alive. Sessions whose
* sandbox has shut down (run completed, failed, cancelled, or task archived or
* deleted) are pruned from the persisted store as their status comes in, so the
* "in progress" list never offers a resume into a dead session.
*/
export function useLoopBuilderSessions(): LoopBuilderSession[] {
const sessions = useLoopBuilderSessionStore((state) => state.sessions);
const archivedTaskIds = useArchivedTaskIds();
const taskIds = useMemo(
() => sessions.map((session) => session.taskId),
[sessions],
);
const {
data: summaries,
isSuccess,
isPlaceholderData,
} = useTaskSummaries(taskIds);

const liveTaskIds = useMemo(() => {
if (!isSuccess || isPlaceholderData) return null;
const live = new Set<string>();
for (const summary of summaries ?? []) {
const run = summary.latest_run;
if (run?.environment === "cloud" && !isTerminalStatus(run.status)) {
live.add(summary.id);
}
}
return live;
}, [isSuccess, isPlaceholderData, summaries]);

useEffect(() => {
if (!liveTaskIds) return;
const store = useLoopBuilderSessionStore.getState();
for (const session of store.sessions) {
const dead =
!liveTaskIds.has(session.taskId) &&
Date.now() - session.startedAt >= FRESH_SESSION_GRACE_MS;
if (dead || archivedTaskIds.has(session.taskId)) {
store.removeSession(session.taskId);
}
}
}, [liveTaskIds, archivedTaskIds]);

return useMemo(
() =>
sessions.filter((session) => {
if (archivedTaskIds.has(session.taskId)) return false;
if (!liveTaskIds) return true;
if (liveTaskIds.has(session.taskId)) return true;
return Date.now() - session.startedAt < FRESH_SESSION_GRACE_MS;
}),
[sessions, archivedTaskIds, liveTaskIds],
);
}
17 changes: 16 additions & 1 deletion packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import type { TaskCreationInput } from "@posthog/core/task-detail/taskService";
import type { Task } from "@posthog/shared/domain-types";
import {
type InboxCloudTaskInputContext,
useInboxCloudTaskRunner,
} from "@posthog/ui/features/inbox/hooks/useInboxCloudTaskRunner";
import { useCallback, useMemo, useRef } from "react";
import { buildLoopBuilderSystemInstructions } from "../loopBuilderPrompt";
import { useLoopBuilderSessionStore } from "../loopBuilderSessionStore";

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

const handleTaskCreated = useCallback((task: Task) => {
useLoopBuilderSessionStore.getState().addSession({
taskId: task.id,
prompt: instructionsRef.current.trim() || "Build a loop",
startedAt: Date.now(),
});
}, []);

const { run, isRunning } = useInboxCloudTaskRunner({
// The loop builder never needs a repo: run repo-less so the sandbox does no
// clone and no GitHub identity is attached.
Expand All @@ -78,6 +92,7 @@ export function useLoopBuilderTask(context?: {
loggerScope: "loop-builder",
copy,
buildInput,
onTaskCreated: handleTaskCreated,
});

const runTask = useCallback(
Expand Down
Loading
Loading