diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml
index bc8c70cd51..a607126f14 100644
--- a/apps/code/snapshots.yml
+++ b/apps/code/snapshots.yml
@@ -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:
diff --git a/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx b/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx
index d2516846f9..6a00977159 100644
--- a/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx
+++ b/packages/ui/src/features/agent-applications/components/AgentChatSurface.tsx
@@ -1,4 +1,4 @@
-import { ArrowUp, Stop } from "@phosphor-icons/react";
+import { ArrowUp, StopCircle } from "@phosphor-icons/react";
import {
InputGroup,
InputGroupAddon,
@@ -179,7 +179,7 @@ function Composer({
onClick={onCancel}
aria-label="Stop"
>
-
+
) : (
diff --git a/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx b/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx
index 78c8bc465f..3465ad2f0f 100644
--- a/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx
+++ b/packages/ui/src/features/autoresearch/AutoresearchPanel.tsx
@@ -1,4 +1,10 @@
-import { ChartLineUp, Pause, Play, Plus, Stop } from "@phosphor-icons/react";
+import {
+ ChartLineUp,
+ Pause,
+ Play,
+ Plus,
+ StopCircle,
+} from "@phosphor-icons/react";
import type { AutoresearchService } from "@posthog/core/autoresearch/autoresearch";
import { AUTORESEARCH_SERVICE } from "@posthog/core/autoresearch/identifiers";
import type {
@@ -344,7 +350,7 @@ function RunHeader({
color="red"
onClick={() => service.stopRun(run.id)}
>
- Stop
+ Stop
)}
{!isLive && (
diff --git a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts
index 86217cadfa..cc225f919d 100644
--- a/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts
+++ b/packages/ui/src/features/inbox/hooks/useInboxCloudTaskRunner.ts
@@ -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";
@@ -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;
+ /** 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
@@ -120,6 +123,7 @@ export function useInboxCloudTaskRunner({
loggerScope,
buildInput,
analyticsExtras,
+ onTaskCreated,
redirectOnSuccess = true,
}: UseInboxCloudTaskRunnerOptions): UseInboxCloudTaskRunnerReturn {
const [isRunning, setIsRunning] = useState(false);
@@ -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);
}
@@ -299,6 +304,7 @@ export function useInboxCloudTaskRunner({
buildInput,
copy,
analyticsExtras,
+ onTaskCreated,
modelResolver,
taskService,
redirectOnSuccess,
diff --git a/packages/ui/src/features/loops/components/LoopRunRow.tsx b/packages/ui/src/features/loops/components/LoopRunRow.tsx
index 66defbfe8a..4d5619ce15 100644
--- a/packages/ui/src/features/loops/components/LoopRunRow.tsx
+++ b/packages/ui/src/features/loops/components/LoopRunRow.tsx
@@ -6,7 +6,6 @@ import {
type Icon,
Lightning,
Play,
- Stop,
Timer,
Warning,
X,
@@ -188,7 +187,6 @@ export function LoopRunRow({
size="1"
onClick={() => setStopOpen(true)}
>
-
Stop run
) : null}
diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx
index 77f6c4a872..3c868e4670 100644
--- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx
+++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx
@@ -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: () => {},
+ },
+};
diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx
index 21f5f1668d..a01b27d0c9 100644
--- a/packages/ui/src/features/loops/components/LoopsListView.tsx
+++ b/packages/ui/src/features/loops/components/LoopsListView.tsx
@@ -1,13 +1,28 @@
-import { CloudIcon, PlusIcon, RepeatIcon } from "@phosphor-icons/react";
+import {
+ ChatCircleDotsIcon,
+ CloudIcon,
+ PlusIcon,
+ RepeatIcon,
+} 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";
@@ -23,6 +38,7 @@ function loopLimitReason(max: number): string {
}
const EMPTY_MEMBERS: UserBasic[] = [];
+const EMPTY_BUILDER_SESSIONS: LoopBuilderSession[] = [];
const SECTION_PREVIEW_COUNT = 5;
@@ -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()
@@ -60,6 +84,8 @@ export function LoopsListView() {
);
useSetHeaderContent(headerContent);
+ const builderSessions = useLoopBuilderSessions();
+
const allLoops = loops ?? [];
const teamLoops = allLoops.filter((loop) => loop.visibility === "team");
const {
@@ -79,8 +105,11 @@ export function LoopsListView() {
membersLoading={membersLoading}
membersError={membersError}
membersComplete={membersComplete}
+ builderSessions={builderSessions}
onStartBlank={startBlankLoop}
onStartFromTemplate={startLoopFromTemplate}
+ onResumeBuilderSession={resumeBuilderSession}
+ onBuilderSessionStopped={removeBuilderSession}
/>
);
}
@@ -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({
@@ -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");
@@ -199,6 +234,14 @@ export function LoopsListViewPresentation({
gap="2"
className="mx-auto w-full max-w-5xl px-8 pb-6"
>
+ {builderSessions.map((session) => (
+
+ ))}
@@ -206,6 +249,64 @@ export function LoopsListViewPresentation({
);
}
+function BuilderSessionRow({
+ session,
+ onResume,
+ onStopped,
+}: {
+ session: LoopBuilderSession;
+ onResume?: (taskId: string) => void;
+ onStopped?: (taskId: string) => void;
+}) {
+ const [confirmStop, setConfirmStop] = useState(false);
+
+ return (
+
+
+
+
+ Builder in progress
+
+
+ {session.prompt}
+
+
+
+
+ {confirmStop ? (
+ {
+ toast.success("Builder stopped");
+ onStopped?.(session.taskId);
+ }}
+ />
+ ) : null}
+
+ );
+}
+
function LoopListSection({
title,
loops,
diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts
new file mode 100644
index 0000000000..e6085ea65d
--- /dev/null
+++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts
@@ -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(() => {
+ // 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]);
+}
diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts
index 284a401976..0b81ff9c09 100644
--- a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts
+++ b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts
@@ -1,10 +1,13 @@
import type { TaskCreationInput } from "@posthog/core/task-detail/taskService";
+import type { Task } from "@posthog/shared/domain-types";
+import { getAuthIdentity, useAuthStore } from "@posthog/ui/features/auth/store";
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. */
@@ -40,7 +43,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 "Loop builder:" prefix marks the sidebar row as a builder session.
+ 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
@@ -70,6 +77,17 @@ export function useLoopBuilderTask(context?: {
[],
);
+ const handleTaskCreated = useCallback((task: Task) => {
+ const identity = getAuthIdentity(useAuthStore.getState().authState);
+ if (!identity) return;
+ useLoopBuilderSessionStore.getState().addSession({
+ taskId: task.id,
+ prompt: instructionsRef.current.trim() || "Build a loop",
+ startedAt: Date.now(),
+ identity,
+ });
+ }, []);
+
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.
@@ -78,6 +96,7 @@ export function useLoopBuilderTask(context?: {
loggerScope: "loop-builder",
copy,
buildInput,
+ onTaskCreated: handleTaskCreated,
});
const runTask = useCallback(
diff --git a/packages/ui/src/features/loops/loopBuilderLiveness.test.ts b/packages/ui/src/features/loops/loopBuilderLiveness.test.ts
new file mode 100644
index 0000000000..53eb478aa1
--- /dev/null
+++ b/packages/ui/src/features/loops/loopBuilderLiveness.test.ts
@@ -0,0 +1,113 @@
+import { expect, it } from "vitest";
+import {
+ type BuilderRunSummary,
+ FRESH_SESSION_GRACE_MS,
+ isBuilderSessionEnded,
+} from "./loopBuilderLiveness";
+import type { LoopBuilderSession } from "./loopBuilderSessionStore";
+
+const NOW = 1_752_000_000_000;
+
+function session(ageMs: number): LoopBuilderSession {
+ return {
+ taskId: "task-1",
+ prompt: "prompt",
+ startedAt: NOW - ageMs,
+ identity: "us:1",
+ };
+}
+
+function summaries(
+ run: BuilderRunSummary | null | undefined,
+): Map {
+ const map = new Map();
+ if (run !== undefined) map.set("task-1", run);
+ return map;
+}
+
+const WITHIN_GRACE = FRESH_SESSION_GRACE_MS - 1_000;
+const PAST_GRACE = FRESH_SESSION_GRACE_MS + 1_000;
+
+it.each([
+ {
+ name: "unknown task within grace stays",
+ ageMs: WITHIN_GRACE,
+ run: undefined,
+ ended: false,
+ },
+ {
+ name: "unknown task past grace is ended",
+ ageMs: PAST_GRACE,
+ run: undefined,
+ ended: true,
+ },
+ {
+ name: "runless task within grace stays",
+ ageMs: WITHIN_GRACE,
+ run: null,
+ ended: false,
+ },
+ {
+ name: "runless task past grace is ended",
+ ageMs: PAST_GRACE,
+ run: null,
+ ended: true,
+ },
+ {
+ name: "running cloud run stays even past grace",
+ ageMs: PAST_GRACE,
+ run: { environment: "cloud", status: "in_progress" },
+ ended: false,
+ },
+ {
+ name: "queued cloud run stays",
+ ageMs: PAST_GRACE,
+ run: { environment: "cloud", status: "queued" },
+ ended: false,
+ },
+ {
+ name: "statusless cloud run stays",
+ ageMs: PAST_GRACE,
+ run: { environment: "cloud", status: null },
+ ended: false,
+ },
+ {
+ name: "completed run is ended even within grace",
+ ageMs: WITHIN_GRACE,
+ run: { environment: "cloud", status: "completed" },
+ ended: true,
+ },
+ {
+ name: "failed run is ended even within grace",
+ ageMs: WITHIN_GRACE,
+ run: { environment: "cloud", status: "failed" },
+ ended: true,
+ },
+ {
+ name: "cancelled run is ended even within grace",
+ ageMs: WITHIN_GRACE,
+ run: { environment: "cloud", status: "cancelled" },
+ ended: true,
+ },
+ {
+ name: "non-cloud run is ended",
+ ageMs: WITHIN_GRACE,
+ run: { environment: "local", status: "in_progress" },
+ ended: true,
+ },
+])("$name", ({ ageMs, run, ended }) => {
+ expect(
+ isBuilderSessionEnded(session(ageMs), summaries(run), new Set(), NOW),
+ ).toBe(ended);
+});
+
+it("archived task is ended regardless of a live run", () => {
+ expect(
+ isBuilderSessionEnded(
+ session(WITHIN_GRACE),
+ summaries({ environment: "cloud", status: "in_progress" }),
+ new Set(["task-1"]),
+ NOW,
+ ),
+ ).toBe(true);
+});
diff --git a/packages/ui/src/features/loops/loopBuilderLiveness.ts b/packages/ui/src/features/loops/loopBuilderLiveness.ts
new file mode 100644
index 0000000000..c8e2da585d
--- /dev/null
+++ b/packages/ui/src/features/loops/loopBuilderLiveness.ts
@@ -0,0 +1,30 @@
+import { isTerminalStatus } from "@posthog/shared/domain-types";
+import type { LoopBuilderSession } from "./loopBuilderSessionStore";
+
+// A fresh task can briefly report no run (or be absent from the summaries
+// response) before its cloud run registers; don't treat that as ended.
+export const FRESH_SESSION_GRACE_MS = 60_000;
+
+export interface BuilderRunSummary {
+ environment: string | null;
+ status: string | null;
+}
+
+/** taskId -> latest run; a null value means the task exists but has no run,
+ * an absent key means the summaries response doesn't know the task at all. */
+export type BuilderRunSummaries = ReadonlyMap;
+
+export function isBuilderSessionEnded(
+ session: LoopBuilderSession,
+ summaries: BuilderRunSummaries,
+ archivedTaskIds: ReadonlySet,
+ now: number,
+): boolean {
+ if (archivedTaskIds.has(session.taskId)) return true;
+ const pastGrace = now - session.startedAt >= FRESH_SESSION_GRACE_MS;
+ if (!summaries.has(session.taskId)) return pastGrace;
+ const run = summaries.get(session.taskId) ?? null;
+ if (!run) return pastGrace;
+ if (run.environment !== "cloud") return true;
+ return isTerminalStatus(run.status);
+}
diff --git a/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts b/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts
new file mode 100644
index 0000000000..840282ac05
--- /dev/null
+++ b/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts
@@ -0,0 +1,78 @@
+import { beforeEach, expect, it, vi } from "vitest";
+import {
+ type LoopBuilderSession,
+ MAX_BUILDER_SESSIONS,
+ useLoopBuilderSessionStore,
+} from "./loopBuilderSessionStore";
+
+vi.mock("@posthog/ui/shell/rendererStorage", () => ({
+ electronStorage: {
+ getItem: async () => null,
+ setItem: async () => {},
+ removeItem: async () => {},
+ },
+ flushRendererStateWrites: async () => {},
+}));
+
+function session(
+ taskId: string,
+ startedAt = 0,
+ identity = "us:1",
+): LoopBuilderSession {
+ return { taskId, prompt: `prompt ${taskId}`, startedAt, identity };
+}
+
+beforeEach(() => {
+ useLoopBuilderSessionStore.setState({ sessions: [] });
+});
+
+it("adds sessions newest first", () => {
+ const store = useLoopBuilderSessionStore.getState();
+ store.addSession(session("a", 1));
+ store.addSession(session("b", 2));
+ expect(
+ useLoopBuilderSessionStore.getState().sessions.map((s) => s.taskId),
+ ).toEqual(["b", "a"]);
+});
+
+it("replaces an existing session with the same task id", () => {
+ const store = useLoopBuilderSessionStore.getState();
+ store.addSession(session("a", 1));
+ store.addSession(session("b", 2));
+ store.addSession({
+ taskId: "a",
+ prompt: "updated",
+ startedAt: 3,
+ identity: "us:1",
+ });
+ const sessions = useLoopBuilderSessionStore.getState().sessions;
+ expect(sessions.map((s) => s.taskId)).toEqual(["a", "b"]);
+ expect(sessions[0]?.prompt).toBe("updated");
+});
+
+it("caps sessions per identity without evicting other identities", () => {
+ const store = useLoopBuilderSessionStore.getState();
+ store.addSession(session("other", 0, "eu:2"));
+ for (let i = 0; i < MAX_BUILDER_SESSIONS + 2; i++) {
+ store.addSession(session(`task-${i}`, i));
+ }
+ const sessions = useLoopBuilderSessionStore.getState().sessions;
+ const mine = sessions.filter((s) => s.identity === "us:1");
+ expect(mine).toHaveLength(MAX_BUILDER_SESSIONS);
+ expect(mine[0]?.taskId).toBe(`task-${MAX_BUILDER_SESSIONS + 1}`);
+ expect(sessions.some((s) => s.taskId === "other")).toBe(true);
+});
+
+it.each([
+ { remove: "a", remaining: ["b"] },
+ { remove: "b", remaining: ["a"] },
+ { remove: "missing", remaining: ["b", "a"] },
+])("removing $remove leaves $remaining", ({ remove, remaining }) => {
+ const store = useLoopBuilderSessionStore.getState();
+ store.addSession(session("a", 1));
+ store.addSession(session("b", 2));
+ store.removeSession(remove);
+ expect(
+ useLoopBuilderSessionStore.getState().sessions.map((s) => s.taskId),
+ ).toEqual(remaining);
+});
diff --git a/packages/ui/src/features/loops/loopBuilderSessionStore.ts b/packages/ui/src/features/loops/loopBuilderSessionStore.ts
new file mode 100644
index 0000000000..33e20aa133
--- /dev/null
+++ b/packages/ui/src/features/loops/loopBuilderSessionStore.ts
@@ -0,0 +1,64 @@
+import {
+ electronStorage,
+ flushRendererStateWrites,
+} from "@posthog/ui/shell/rendererStorage";
+import { create } from "zustand";
+import { persist } from "zustand/middleware";
+
+export interface LoopBuilderSession {
+ taskId: string;
+ prompt: string;
+ startedAt: number;
+ /** Auth identity (`getAuthIdentity`) the session belongs to. Sessions are
+ * only shown, pruned and capped within their own identity, so prompts never
+ * leak across accounts or projects sharing a device. */
+ identity: string;
+}
+
+export const MAX_BUILDER_SESSIONS = 5;
+
+interface LoopBuilderSessionState {
+ sessions: LoopBuilderSession[];
+ addSession: (session: LoopBuilderSession) => void;
+ removeSession: (taskId: string) => void;
+}
+
+export const useLoopBuilderSessionStore = create()(
+ persist(
+ (set) => ({
+ sessions: [],
+ // Flushed immediately: adding is followed by navigating away, and a lost
+ // debounced write is exactly the "can't find my builder" bug again.
+ addSession: (session) => {
+ set((state) => {
+ const others = state.sessions.filter(
+ (s) => s.identity !== session.identity,
+ );
+ const mine = [
+ session,
+ ...state.sessions.filter(
+ (s) =>
+ s.identity === session.identity && s.taskId !== session.taskId,
+ ),
+ ].slice(0, MAX_BUILDER_SESSIONS);
+ return { sessions: [...mine, ...others] };
+ });
+ void flushRendererStateWrites();
+ },
+ removeSession: (taskId) => {
+ set((state) => ({
+ sessions: state.sessions.filter((s) => s.taskId !== taskId),
+ }));
+ void flushRendererStateWrites();
+ },
+ }),
+ {
+ name: "posthog-code-loop-builder-sessions",
+ storage: electronStorage,
+ partialize: (state) => ({ sessions: state.sessions }),
+ // v0 entries had no identity and can't be attributed; drop them.
+ version: 1,
+ migrate: () => ({ sessions: [] as LoopBuilderSession[] }),
+ },
+ ),
+);
diff --git a/packages/ui/src/features/message-editor/components/PromptInput.tsx b/packages/ui/src/features/message-editor/components/PromptInput.tsx
index a50af3c624..9d8c124b21 100644
--- a/packages/ui/src/features/message-editor/components/PromptInput.tsx
+++ b/packages/ui/src/features/message-editor/components/PromptInput.tsx
@@ -1,6 +1,6 @@
import "./message-editor.css";
import type { SessionConfigOption } from "@agentclientprotocol/sdk";
-import { ArrowUp, Stop } from "@phosphor-icons/react";
+import { ArrowUp, StopCircle } from "@phosphor-icons/react";
import { InputGroup, InputGroupAddon, InputGroupButton } from "@posthog/quill";
import { SHORTCUTS } from "@posthog/ui/features/command/keyboard-shortcuts";
import type { PromptRecallHandler } from "@posthog/ui/features/sessions/components/chat-thread/composerPromptRecall";
@@ -366,7 +366,7 @@ export const PromptInput = forwardRef(
onClick={onCancel}
aria-label="Stop"
>
-
+
) : slotMachineMode ? null : (
diff --git a/packages/ui/src/features/sessions/components/StopCloudRunButton.tsx b/packages/ui/src/features/sessions/components/StopCloudRunButton.tsx
index 6836985673..3362b5682d 100644
--- a/packages/ui/src/features/sessions/components/StopCloudRunButton.tsx
+++ b/packages/ui/src/features/sessions/components/StopCloudRunButton.tsx
@@ -1,4 +1,4 @@
-import { Spinner, Stop } from "@phosphor-icons/react";
+import { Spinner, StopCircle } from "@phosphor-icons/react";
import { isTerminalStatus } from "@posthog/core/cloud-task/schemas";
import { Button as QuillButton } from "@posthog/quill";
import { useState } from "react";
@@ -36,7 +36,7 @@ export function StopCloudRunButton({ taskId }: StopCloudRunButtonProps) {
{stopRequested ? (
) : (
-
+
)}
{stopRequested ? "Stopping..." : "Stop run"}
diff --git a/packages/ui/src/features/sessions/components/StopCloudRunDialog.tsx b/packages/ui/src/features/sessions/components/StopCloudRunDialog.tsx
index 8e651b4c8a..011c8451f2 100644
--- a/packages/ui/src/features/sessions/components/StopCloudRunDialog.tsx
+++ b/packages/ui/src/features/sessions/components/StopCloudRunDialog.tsx
@@ -1,4 +1,4 @@
-import { Stop } from "@phosphor-icons/react";
+import { StopCircle } from "@phosphor-icons/react";
import {
SESSION_SERVICE,
type SessionService,
@@ -58,7 +58,7 @@ export function StopCloudRunDialog({
}
+ icon={}
title={title}
error={error}
buttonLabel={buttonLabel}
diff --git a/packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx b/packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx
index 0d26837d9d..55451cecfb 100644
--- a/packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx
+++ b/packages/ui/src/features/sessions/components/session-update/TaskNotificationView.tsx
@@ -1,4 +1,4 @@
-import { CheckCircle, Stop, XCircle } from "@phosphor-icons/react";
+import { CheckCircle, StopCircle, XCircle } from "@phosphor-icons/react";
import { Box, Flex, Text } from "@radix-ui/themes";
interface TaskNotificationViewProps {
@@ -18,7 +18,7 @@ const statusConfig = {
borderColor: "border-red-6 dark:border-red-8",
},
stopped: {
- icon: ,
+ icon: ,
label: "Task stopped",
borderColor: "border-orange-6 dark:border-orange-8",
},
diff --git a/packages/ui/src/features/settings/sections/AddCustomSoundDialog.tsx b/packages/ui/src/features/settings/sections/AddCustomSoundDialog.tsx
index f9fd3c5d7b..515361644d 100644
--- a/packages/ui/src/features/settings/sections/AddCustomSoundDialog.tsx
+++ b/packages/ui/src/features/settings/sections/AddCustomSoundDialog.tsx
@@ -3,7 +3,7 @@ import {
Microphone,
Play,
Scissors,
- Stop,
+ StopCircle,
Trash,
UploadSimple,
} from "@phosphor-icons/react";
@@ -66,7 +66,7 @@ export function AddCustomSoundDialog({
{sound.isRecording ? (
) : (