From 0bf2a9e9983ca2ac95eb6b0cc70b9550239e5152 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 01:46:21 -0700 Subject: [PATCH 1/7] make loop builder sessions resumable --- .../inbox/hooks/useInboxCloudTaskRunner.ts | 6 + .../components/LoopsListView.stories.tsx | 19 ++++ .../loops/components/LoopsListView.tsx | 103 +++++++++++++++++- .../loops/hooks/useLoopBuilderSessions.ts | 68 ++++++++++++ .../loops/hooks/useLoopBuilderTask.ts | 17 ++- .../loops/loopBuilderSessionStore.test.ts | 66 +++++++++++ .../features/loops/loopBuilderSessionStore.ts | 50 +++++++++ 7 files changed, 326 insertions(+), 3 deletions(-) create mode 100644 packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts create mode 100644 packages/ui/src/features/loops/loopBuilderSessionStore.test.ts create mode 100644 packages/ui/src/features/loops/loopBuilderSessionStore.ts 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/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index 77f6c4a872..dc9da15eba 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -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: () => {}, + }, +}; diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 21f5f1668d..4bb2854b5b 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, + 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"; @@ -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,62 @@ 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 ? ( + 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..90c67d29d1 --- /dev/null +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts @@ -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(); + 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], + ); +} diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts index 284a401976..d554e7fa12 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts @@ -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. */ @@ -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 @@ -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. @@ -78,6 +92,7 @@ export function useLoopBuilderTask(context?: { loggerScope: "loop-builder", copy, buildInput, + onTaskCreated: handleTaskCreated, }); const runTask = useCallback( 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..220b1d375c --- /dev/null +++ b/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts @@ -0,0 +1,66 @@ +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): LoopBuilderSession { + return { taskId, prompt: `prompt ${taskId}`, startedAt }; +} + +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 }); + const sessions = useLoopBuilderSessionStore.getState().sessions; + expect(sessions.map((s) => s.taskId)).toEqual(["a", "b"]); + expect(sessions[0]?.prompt).toBe("updated"); +}); + +it("caps the list at the max session count", () => { + const store = useLoopBuilderSessionStore.getState(); + for (let i = 0; i < MAX_BUILDER_SESSIONS + 2; i++) { + store.addSession(session(`task-${i}`, i)); + } + const sessions = useLoopBuilderSessionStore.getState().sessions; + expect(sessions).toHaveLength(MAX_BUILDER_SESSIONS); + expect(sessions[0]?.taskId).toBe(`task-${MAX_BUILDER_SESSIONS + 1}`); +}); + +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..1dfa56b29d --- /dev/null +++ b/packages/ui/src/features/loops/loopBuilderSessionStore.ts @@ -0,0 +1,50 @@ +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; +} + +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) => ({ + sessions: [ + session, + ...state.sessions.filter((s) => s.taskId !== session.taskId), + ].slice(0, MAX_BUILDER_SESSIONS), + })); + 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 }), + }, + ), +); From 228c2eeab9d7e557eae2e61d17749164acee7bb0 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 02:01:46 -0700 Subject: [PATCH 2/7] fix builder session pruning and row style --- .../loops/components/LoopsListView.tsx | 16 ++- .../loops/hooks/useLoopBuilderSessions.ts | 89 ++++++++------- .../loops/hooks/useLoopBuilderTask.ts | 2 +- .../loops/loopBuilderLiveness.test.ts | 108 ++++++++++++++++++ .../src/features/loops/loopBuilderLiveness.ts | 30 +++++ 5 files changed, 199 insertions(+), 46 deletions(-) create mode 100644 packages/ui/src/features/loops/loopBuilderLiveness.test.ts create mode 100644 packages/ui/src/features/loops/loopBuilderLiveness.ts diff --git a/packages/ui/src/features/loops/components/LoopsListView.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index 4bb2854b5b..a129c51c6a 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -11,6 +11,7 @@ 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 { toast } from "@posthog/ui/primitives/toast"; import { navigateToNewLoop, navigateToTaskDetail, @@ -264,11 +265,11 @@ function BuilderSessionRow({ - + Builder in progress @@ -283,12 +284,12 @@ function BuilderSessionRow({ Resume {confirmStop ? ( @@ -298,7 +299,10 @@ function BuilderSessionRow({ title="Stop loop builder" buttonLabel="Stop builder" onOpenChange={setConfirmStop} - onStopped={() => onStopped?.(session.taskId)} + onStopped={() => { + toast.success("Builder stopped"); + onStopped?.(session.taskId); + }} /> ) : null} diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts index 90c67d29d1..a511d6fd18 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts @@ -1,21 +1,22 @@ -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 { useEffect, useMemo, useState } from "react"; +import { + type BuilderRunSummaries, + FRESH_SESSION_GRACE_MS, + isBuilderSessionEnded, +} from "../loopBuilderLiveness"; 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. + * "in progress" list never offers a resume into a dead session. The liveness + * decision itself is the pure `isBuilderSessionEnded`. */ export function useLoopBuilderSessions(): LoopBuilderSession[] { const sessions = useLoopBuilderSessionStore((state) => state.sessions); @@ -24,45 +25,55 @@ export function useLoopBuilderSessions(): LoopBuilderSession[] { () => sessions.map((session) => session.taskId), [sessions], ); - const { - data: summaries, - isSuccess, - isPlaceholderData, - } = useTaskSummaries(taskIds); + const { data, isSuccess, isPlaceholderData } = useTaskSummaries(taskIds); - const liveTaskIds = useMemo(() => { - if (!isSuccess || isPlaceholderData) return null; - const live = new Set(); - 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]); + 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(() => { - if (!liveTaskIds) return; + 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) 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)) { + if (isBuilderSessionEnded(session, summaries, archivedTaskIds, now)) { store.removeSession(session.taskId); } } - }, [liveTaskIds, archivedTaskIds]); + }, [summaries, archivedTaskIds, now]); - 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], - ); + 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 d554e7fa12..69eaa65f5c 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts @@ -43,7 +43,7 @@ export function useLoopBuilderTask(context?: { return { content: taskContent, // Divergent on purpose: the description becomes the task's title, so - // the sidebar row reads as the builder instead of the raw prompt. + // the "Loop builder:" prefix marks the sidebar row as a builder session. taskDescription: hasSeed ? `Loop builder: ${userPrompt}` : "Loop builder", 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..31f2deae80 --- /dev/null +++ b/packages/ui/src/features/loops/loopBuilderLiveness.test.ts @@ -0,0 +1,108 @@ +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 }; +} + +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); +} From b89e9c6c90a5f3ef0d0d7ed37af93c6a8fcae645 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:02:07 +0000 Subject: [PATCH 3/7] chore(visual): update storybook baselines 2 updated Run: f8911d32-bff0-4dcb-bef3-5575a0094281 Co-authored-by: charlesvien <5378415+charlesvien@users.noreply.github.com> --- apps/code/snapshots.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index bc8c70cd51..72c4156389 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.e3ccb5aff6e414d12af88793be44c744a89f087ddc6a545ca47f84c014eed90d.rMkVguChrrozFOmRtg-tmj1SnnqDqVdYeuYnmca2oSc + loops-loopslistview--with-builder-sessions--light: + hash: v1.k4693efd2.05fc66526e11acfe47d25e9c40b7195e120a99d9ee92b11d698aebbdb667d947.wfSF6IDXjtcVeLL-pJdMavw_CX1KuCjFrzVqmJjx4FY scouts-scoutsfleetlist--filtered-to-you--dark: hash: v1.k4693efd2.a3af6e76ef36598eaa347bedc4430878ed62754660166398e0576a9978cd084b.x3Ky-O6HOw0srWdOmnnKJwFNpmGmQVWip0t7CwVaRXY scouts-scoutsfleetlist--filtered-to-you--light: From d583d635697b80c93ccc23d77d86a720bd932233 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 02:04:38 -0700 Subject: [PATCH 4/7] scope builder sessions to the auth identity --- .../components/LoopsListView.stories.tsx | 2 ++ .../loops/hooks/useLoopBuilderSessions.ts | 31 ++++++++++++++----- .../loops/hooks/useLoopBuilderTask.ts | 4 +++ .../loops/loopBuilderLiveness.test.ts | 7 ++++- .../loops/loopBuilderSessionStore.test.ts | 24 ++++++++++---- .../features/loops/loopBuilderSessionStore.ts | 24 +++++++++++--- 6 files changed, 72 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx index dc9da15eba..3c868e4670 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.stories.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.stories.tsx @@ -181,11 +181,13 @@ export const WithBuilderSessions: Story = { 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: () => {}, diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts index a511d6fd18..e6085ea65d 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderSessions.ts @@ -1,4 +1,8 @@ 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 { @@ -12,14 +16,24 @@ import { } from "../loopBuilderSessionStore"; /** - * 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. The liveness - * decision itself is the pure `isBuilderSessionEnded`. + * 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 sessions = useLoopBuilderSessionStore((state) => state.sessions); + 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), @@ -58,14 +72,15 @@ export function useLoopBuilderSessions(): LoopBuilderSession[] { }, [sessions, now]); useEffect(() => { - if (!summaries) return; + 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]); + }, [summaries, archivedTaskIds, now, identity]); return useMemo(() => { if (!summaries) { diff --git a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts index 69eaa65f5c..0b81ff9c09 100644 --- a/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts +++ b/packages/ui/src/features/loops/hooks/useLoopBuilderTask.ts @@ -1,5 +1,6 @@ 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, @@ -77,10 +78,13 @@ 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, }); }, []); diff --git a/packages/ui/src/features/loops/loopBuilderLiveness.test.ts b/packages/ui/src/features/loops/loopBuilderLiveness.test.ts index 31f2deae80..53eb478aa1 100644 --- a/packages/ui/src/features/loops/loopBuilderLiveness.test.ts +++ b/packages/ui/src/features/loops/loopBuilderLiveness.test.ts @@ -9,7 +9,12 @@ 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 }; + return { + taskId: "task-1", + prompt: "prompt", + startedAt: NOW - ageMs, + identity: "us:1", + }; } function summaries( diff --git a/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts b/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts index 220b1d375c..840282ac05 100644 --- a/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts +++ b/packages/ui/src/features/loops/loopBuilderSessionStore.test.ts @@ -14,8 +14,12 @@ vi.mock("@posthog/ui/shell/rendererStorage", () => ({ flushRendererStateWrites: async () => {}, })); -function session(taskId: string, startedAt = 0): LoopBuilderSession { - return { taskId, prompt: `prompt ${taskId}`, startedAt }; +function session( + taskId: string, + startedAt = 0, + identity = "us:1", +): LoopBuilderSession { + return { taskId, prompt: `prompt ${taskId}`, startedAt, identity }; } beforeEach(() => { @@ -35,20 +39,28 @@ 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 }); + 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 the list at the max session count", () => { +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; - expect(sessions).toHaveLength(MAX_BUILDER_SESSIONS); - expect(sessions[0]?.taskId).toBe(`task-${MAX_BUILDER_SESSIONS + 1}`); + 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([ diff --git a/packages/ui/src/features/loops/loopBuilderSessionStore.ts b/packages/ui/src/features/loops/loopBuilderSessionStore.ts index 1dfa56b29d..33e20aa133 100644 --- a/packages/ui/src/features/loops/loopBuilderSessionStore.ts +++ b/packages/ui/src/features/loops/loopBuilderSessionStore.ts @@ -9,6 +9,10 @@ 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; @@ -26,12 +30,19 @@ export const useLoopBuilderSessionStore = create()( // 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) => ({ - sessions: [ + set((state) => { + const others = state.sessions.filter( + (s) => s.identity !== session.identity, + ); + const mine = [ session, - ...state.sessions.filter((s) => s.taskId !== session.taskId), - ].slice(0, MAX_BUILDER_SESSIONS), - })); + ...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) => { @@ -45,6 +56,9 @@ export const useLoopBuilderSessionStore = create()( 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[] }), }, ), ); From 035e9f57b47bc6069db7f9b4eba94ef84d92fe2b Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 23 Jul 2026 09:09:31 +0000 Subject: [PATCH 5/7] chore(visual): update storybook baselines 2 updated Run: 78b2e825-5647-4b0d-ae22-53a298a35783 Co-authored-by: charlesvien <5378415+charlesvien@users.noreply.github.com> --- apps/code/snapshots.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/code/snapshots.yml b/apps/code/snapshots.yml index 72c4156389..a607126f14 100644 --- a/apps/code/snapshots.yml +++ b/apps/code/snapshots.yml @@ -577,9 +577,9 @@ snapshots: loops-loopslistview--long-mixed-list--light: hash: v1.k4693efd2.d80a0c55c4490276df53f781ac88ed9d9c6ec282bc14ddb80140b3ddcc44d32f.L6hw9ZixUiyTFKOybYzmjfUSIOayvmhueMTcmYt-IWo loops-loopslistview--with-builder-sessions--dark: - hash: v1.k4693efd2.e3ccb5aff6e414d12af88793be44c744a89f087ddc6a545ca47f84c014eed90d.rMkVguChrrozFOmRtg-tmj1SnnqDqVdYeuYnmca2oSc + hash: v1.k4693efd2.bc59812bf49ff161cf20e75dcf043367ae3d2817aa2c2cf91c91d9771c6b62f8.yOBz-llmwDVay7GIHlMwlMT3EuKjkoW0Vwmwvi4noCM loops-loopslistview--with-builder-sessions--light: - hash: v1.k4693efd2.05fc66526e11acfe47d25e9c40b7195e120a99d9ee92b11d698aebbdb667d947.wfSF6IDXjtcVeLL-pJdMavw_CX1KuCjFrzVqmJjx4FY + hash: v1.k4693efd2.fb97e3fe3205ddf71d98c4e35446c73e394aaa9761d3bb35b146ddafc438df4d.UUEWc0G12NQibZTyskIHGS62mB36gKJ594GrJp1E9II scouts-scoutsfleetlist--filtered-to-you--dark: hash: v1.k4693efd2.a3af6e76ef36598eaa347bedc4430878ed62754660166398e0576a9978cd084b.x3Ky-O6HOw0srWdOmnnKJwFNpmGmQVWip0t7CwVaRXY scouts-scoutsfleetlist--filtered-to-you--light: From a8ff0f7d70e615bc6872531431071df85888e735 Mon Sep 17 00:00:00 2001 From: Charles Vien Date: Thu, 23 Jul 2026 02:12:05 -0700 Subject: [PATCH 6/7] put stop first and drop the stop glyph --- .../ui/src/features/loops/components/LoopRunRow.tsx | 2 -- .../src/features/loops/components/LoopsListView.tsx | 12 +++++------- 2 files changed, 5 insertions(+), 9 deletions(-) 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.tsx b/packages/ui/src/features/loops/components/LoopsListView.tsx index a129c51c6a..a01b27d0c9 100644 --- a/packages/ui/src/features/loops/components/LoopsListView.tsx +++ b/packages/ui/src/features/loops/components/LoopsListView.tsx @@ -3,7 +3,6 @@ import { CloudIcon, PlusIcon, RepeatIcon, - StopIcon, } from "@phosphor-icons/react"; import type { LoopSchemas } from "@posthog/api-client/loops"; import type { UserBasic } from "@posthog/shared/domain-types"; @@ -278,19 +277,18 @@ function BuilderSessionRow({ {confirmStop ? ( Date: Thu, 23 Jul 2026 02:18:11 -0700 Subject: [PATCH 7/7] replace square stop icon with stop circle --- .../agent-applications/components/AgentChatSurface.tsx | 4 ++-- .../ui/src/features/autoresearch/AutoresearchPanel.tsx | 10 ++++++++-- .../features/message-editor/components/PromptInput.tsx | 4 ++-- .../sessions/components/StopCloudRunButton.tsx | 4 ++-- .../sessions/components/StopCloudRunDialog.tsx | 4 ++-- .../components/session-update/TaskNotificationView.tsx | 4 ++-- .../settings/sections/AddCustomSoundDialog.tsx | 4 ++-- 7 files changed, 20 insertions(+), 14 deletions(-) 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/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 ? ( ) : (