Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
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
4 changes: 4 additions & 0 deletions apps/code/snapshots.yml
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,10 @@ snapshots:
hash: v1.k4693efd2.384486583f8d9b66a4bf1d9cc45036a07310cb9e1943ea5b52ff466f55a03d7b.F_sXrJ13XWHzAF8yhoKun_k4BY8Jcb3EDtT0L1I6nKw
loops-loopslistview--long-mixed-list--light:
hash: v1.k4693efd2.d80a0c55c4490276df53f781ac88ed9d9c6ec282bc14ddb80140b3ddcc44d32f.L6hw9ZixUiyTFKOybYzmjfUSIOayvmhueMTcmYt-IWo
loops-loopslistview--with-builder-sessions--dark:
hash: v1.k4693efd2.bc59812bf49ff161cf20e75dcf043367ae3d2817aa2c2cf91c91d9771c6b62f8.yOBz-llmwDVay7GIHlMwlMT3EuKjkoW0Vwmwvi4noCM
loops-loopslistview--with-builder-sessions--light:
hash: v1.k4693efd2.fb97e3fe3205ddf71d98c4e35446c73e394aaa9761d3bb35b146ddafc438df4d.UUEWc0G12NQibZTyskIHGS62mB36gKJ594GrJp1E9II
scouts-scoutsfleetlist--filtered-to-you--dark:
hash: v1.k4693efd2.a3af6e76ef36598eaa347bedc4430878ed62754660166398e0576a9978cd084b.x3Ky-O6HOw0srWdOmnnKJwFNpmGmQVWip0t7CwVaRXY
scouts-scoutsfleetlist--filtered-to-you--light:
Expand Down
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,24 @@ export const LongMixedList: Story = {
}),
},
};

export const WithBuilderSessions: Story = {
args: {
builderSessions: [
{
taskId: "builder-task-1",
prompt: "Summarize my open PRs every weekday morning",
startedAt: 1752000000000,
identity: "us:2",
},
{
taskId: "builder-task-2",
prompt: "Build a loop",
startedAt: 1752000600000,
identity: "us:2",
},
],
onResumeBuilderSession: () => {},
onBuilderSessionStopped: () => {},
},
};
107 changes: 105 additions & 2 deletions packages/ui/src/features/loops/components/LoopsListView.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,29 @@
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 { toast } from "@posthog/ui/primitives/toast";
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 +39,7 @@ function loopLimitReason(max: number): string {
}

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

const SECTION_PREVIEW_COUNT = 5;

Expand All @@ -31,6 +48,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 +85,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 +106,11 @@ export function LoopsListView() {
membersLoading={membersLoading}
membersError={membersError}
membersComplete={membersComplete}
builderSessions={builderSessions}
onStartBlank={startBlankLoop}
onStartFromTemplate={startLoopFromTemplate}
onResumeBuilderSession={resumeBuilderSession}
onBuilderSessionStopped={removeBuilderSession}
/>
);
}
Expand All @@ -94,8 +124,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 +140,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 +235,80 @@ 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-2) 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-[12px] text-gray-10 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="soft"
color="red"
size="1"
onClick={() => setConfirmStop(true)}
>
<StopIcon size={12} weight="bold" />
Stop
</Button>
{confirmStop ? (
<StopCloudRunDialog
open={confirmStop}
taskId={session.taskId}
title="Stop loop builder"
buttonLabel="Stop builder"
onOpenChange={setConfirmStop}
onStopped={() => {
toast.success("Builder stopped");
onStopped?.(session.taskId);
}}
/>
) : null}
</Flex>
);
}

function LoopListSection({
title,
loops,
Expand Down
94 changes: 94 additions & 0 deletions packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { useArchivedTaskIds } from "@posthog/ui/features/archive/useArchivedTaskIds";
import {
getAuthIdentity,
useAuthStateValue,
} from "@posthog/ui/features/auth/store";
import { useTaskSummaries } from "@posthog/ui/features/tasks/useTasks";
import { useEffect, useMemo, useState } from "react";
import {
type BuilderRunSummaries,
FRESH_SESSION_GRACE_MS,
isBuilderSessionEnded,
} from "../loopBuilderLiveness";
import {
type LoopBuilderSession,
useLoopBuilderSessionStore,
} from "../loopBuilderSessionStore";

/**
* The current identity's 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. Other identities' sessions are never shown or pruned: the summaries
* this hook queries are only authoritative for the signed-in account. The
* liveness decision itself is the pure `isBuilderSessionEnded`.
*/
export function useLoopBuilderSessions(): LoopBuilderSession[] {
const identity = useAuthStateValue(getAuthIdentity);
const allSessions = useLoopBuilderSessionStore((state) => state.sessions);
const sessions = useMemo(
() =>
identity
? allSessions.filter((session) => session.identity === identity)
: [],
[allSessions, identity],
);
const archivedTaskIds = useArchivedTaskIds();
const taskIds = useMemo(
() => sessions.map((session) => session.taskId),
[sessions],
);
const { data, isSuccess, isPlaceholderData } = useTaskSummaries(taskIds);

const summaries = useMemo<BuilderRunSummaries | null>(() => {
// Placeholder data is the previous id set's response; judging liveness on
// it would prune a just-added session that isn't in that response yet.
if (!isSuccess || isPlaceholderData || !data) return null;
return new Map(
data.map((summary) => [
summary.id,
summary.latest_run
? {
environment: summary.latest_run.environment,
status: summary.latest_run.status,
}
: null,
]),
);
}, [isSuccess, isPlaceholderData, data]);

// Grace expiry doesn't produce a re-render by itself (polled summaries keep
// their identity when nothing changed), so schedule one for the soonest
// boundary; `now` is otherwise only refreshed by real data changes.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const waits = sessions
.map((session) => session.startedAt + FRESH_SESSION_GRACE_MS - now)
.filter((wait) => wait > 0);
if (waits.length === 0) return;
const timer = setTimeout(() => setNow(Date.now()), Math.min(...waits) + 50);
return () => clearTimeout(timer);
}, [sessions, now]);

useEffect(() => {
if (!summaries || !identity) return;
const store = useLoopBuilderSessionStore.getState();
for (const session of store.sessions) {
if (session.identity !== identity) continue;
if (isBuilderSessionEnded(session, summaries, archivedTaskIds, now)) {
store.removeSession(session.taskId);
}
}
}, [summaries, archivedTaskIds, now, identity]);

return useMemo(() => {
if (!summaries) {
return sessions.filter((session) => !archivedTaskIds.has(session.taskId));
}
return sessions.filter(
(session) =>
!isBuilderSessionEnded(session, summaries, archivedTaskIds, now),
);
}, [sessions, summaries, archivedTaskIds, now]);
}
Loading
Loading