diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index a5a01c842..cbf389141 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -230,6 +230,7 @@ interface CloudRunOptions { runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: PermissionMode; + homeQuickAction?: string; } interface CreateTaskRunOptions extends CloudRunOptions { @@ -308,6 +309,9 @@ function buildCloudRunRequestBody( if (options?.initialPermissionMode) { body.initial_permission_mode = options.initialPermissionMode; } + if (options?.homeQuickAction) { + body.home_quick_action = options.homeQuickAction; + } return body; } diff --git a/packages/core/src/home/schemas.ts b/packages/core/src/home/schemas.ts index 71fac7ee8..0ab439eb5 100644 --- a/packages/core/src/home/schemas.ts +++ b/packages/core/src/home/schemas.ts @@ -39,6 +39,9 @@ export const homeWorkstreamTask = z status: taskRunStatus.nullable(), isGenerating: z.boolean(), needsPermission: z.boolean(), + // Label of the Home quick action that started this run, when it came from one. + // Optional for tolerance of snapshots produced before this field shipped. + quickAction: z.string().nullable().optional(), }) .strict(); export type HomeWorkstreamTask = z.infer; diff --git a/packages/core/src/task-detail/taskCreationApiClient.ts b/packages/core/src/task-detail/taskCreationApiClient.ts index 7c00dea6b..3ef2f5ed5 100644 --- a/packages/core/src/task-detail/taskCreationApiClient.ts +++ b/packages/core/src/task-detail/taskCreationApiClient.ts @@ -13,6 +13,7 @@ export interface CreateTaskRunClientOptions { runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: string; + homeQuickAction?: string; } export interface StartTaskRunClientOptions { diff --git a/packages/core/src/task-detail/taskCreationSaga.ts b/packages/core/src/task-detail/taskCreationSaga.ts index 42a532292..e786ca09c 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -246,6 +246,7 @@ export class TaskCreationSaga extends Saga< prAuthorshipMode, runSource: input.cloudRunSource ?? "manual", signalReportId: input.signalReportId, + homeQuickAction: input.homeQuickActionLabel, initialPermissionMode: input.adapter ? (input.executionMode ?? (input.adapter === "codex" ? "auto" : "plan")) diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 7c28b4643..c6271ca8a 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -32,6 +32,9 @@ export interface TaskCreationInput { cloudRunSource?: CloudRunSource; signalReportId?: string; additionalDirectories?: string[]; + // Label of the Home-tab quick action that started this run (e.g. "Fix CI"), so the + // workstream can show which quick actions have been run against it. + homeQuickActionLabel?: string; } export interface TaskCreationOutput { diff --git a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx b/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx index d0105a784..913f3c609 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamCard.tsx @@ -1,4 +1,5 @@ import { + CircleNotch, GitBranch, GitPullRequest, Sparkle, @@ -41,6 +42,7 @@ export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { showTaskInMenu, hasMenu, runAction, + isRunningAction, openTask, openPr, } = useWorkstreamPresentation(workstream); @@ -159,10 +161,15 @@ export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { ) : primaryIsPr ? ( @@ -193,6 +200,7 @@ export function HomeWorkstreamCard({ workstream }: HomeWorkstreamCardProps) { onOpenPr={openPr} onOpenTask={openTask} size="xs" + runDisabled={isRunningAction} /> ) : null} diff --git a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx index 821b18755..71743b310 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -26,6 +26,7 @@ import { useBoundActions, } from "@posthog/ui/features/home/hooks/useBoundActions"; import { useRunWorkstreamAction } from "@posthog/ui/features/home/hooks/useRunWorkstreamAction"; +import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; import { useTasks } from "@posthog/ui/features/tasks/useTasks"; import { openTask } from "@posthog/ui/router/useOpenTask"; import { openUrlInBrowser } from "@posthog/ui/utils/browser"; @@ -41,7 +42,10 @@ interface Props { export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { const { data: allTasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const runAction = useRunWorkstreamAction(); + const { run: runAction } = useRunWorkstreamAction(); + const isRunningAction = useQuickActionStore( + (s) => !!s.inFlight[workstream.id], + ); const pr = workstream.pr; const headTask = workstream.tasks[0]; @@ -124,10 +128,15 @@ export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { ) : null} @@ -143,6 +152,7 @@ export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { {overflowActions.map((action: BoundAction) => ( runAction(action, workstream)} > @@ -306,6 +316,15 @@ function TaskRow({ {task.title} + {task.quickAction ? ( + + + {task.quickAction} + + ) : null} {task.needsPermission ? ( ! diff --git a/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx b/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx index 097fde186..26c50ecef 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamRow.tsx @@ -1,5 +1,6 @@ import { ChatCircle, + CircleNotch, GitBranch, GitPullRequest, Sparkle, @@ -35,6 +36,7 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { extraSituations, generating, needsPermission, + quickActions, primaryBound, restBound, primaryIsPr, @@ -43,6 +45,7 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { showTaskInMenu, hasMenu, runAction, + isRunningAction, openTask, openPr, } = useWorkstreamPresentation(workstream); @@ -104,6 +107,21 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { ), }); } + if (quickActions.length > 0) { + meta.push({ + key: "quick-actions", + node: ( + + + {quickActions.slice(0, 2).join(", ")} + {quickActions.length > 2 ? ` +${quickActions.length - 2}` : ""} + + ), + }); + } return ( runAction(primaryBound)} title={`${primaryBound.situationLabel} → ${primaryBound.skillId}`} > - + {isRunningAction ? ( + + ) : ( + + )} {primaryBound.label} ) : primaryIsPr ? ( @@ -185,6 +208,7 @@ export function HomeWorkstreamRow({ workstream }: HomeWorkstreamRowProps) { onOpenPr={openPr} onOpenTask={openTask} size="sm" + runDisabled={isRunningAction} /> ) : null} diff --git a/packages/ui/src/features/home/components/WorkstreamBits.tsx b/packages/ui/src/features/home/components/WorkstreamBits.tsx index 618d5ec38..bd408c515 100644 --- a/packages/ui/src/features/home/components/WorkstreamBits.tsx +++ b/packages/ui/src/features/home/components/WorkstreamBits.tsx @@ -136,6 +136,7 @@ export function WorkstreamOverflowMenu({ onOpenPr, onOpenTask, size = "sm", + runDisabled = false, }: { restBound: BoundAction[]; showPrInMenu: boolean; @@ -144,6 +145,8 @@ export function WorkstreamOverflowMenu({ onOpenPr: () => void; onOpenTask: () => void; size?: "sm" | "xs"; + /** Disables the task-starting actions while one is already in flight. */ + runDisabled?: boolean; }) { const sparkleSize = size === "xs" ? 11 : 12; const dotsSize = size === "xs" ? 15 : 16; @@ -158,6 +161,7 @@ export function WorkstreamOverflowMenu({ {restBound.map((action) => ( onRun(action)} > diff --git a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts index 8eacc466f..1887fadb3 100644 --- a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts +++ b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts @@ -1,4 +1,4 @@ -import type { HomeWorkstream } from "@posthog/core/home/schemas"; +import type { HomeSnapshot, HomeWorkstream } from "@posthog/core/home/schemas"; import { REPORT_MODEL_RESOLVER, type ReportModelResolver, @@ -12,21 +12,28 @@ import { type TaskCreationInput, } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { homeKeys } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; +import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; +import { insertOptimisticTask } from "@posthog/ui/features/home/utils/optimisticTask"; import { useUserRepositoryIntegration } from "@posthog/ui/features/integrations/useIntegrations"; import { useSettingsStore } from "@posthog/ui/features/settings/settingsStore"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; +import { useAuthenticatedMutation } from "@posthog/ui/hooks/useAuthenticatedMutation"; import { useConnectivity } from "@posthog/ui/hooks/useConnectivity"; import { toast } from "@posthog/ui/primitives/toast"; -import { navigateToTaskPending } from "@posthog/ui/router/navigationBridge"; -import { openTask, openTaskInput } from "@posthog/ui/router/useOpenTask"; +import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; -import { pendingTaskPromptStoreApi } from "@posthog/ui/shell/pendingTaskPromptStore"; -import { useCallback, useRef } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useCallback } from "react"; import type { BoundAction } from "./useBoundActions"; const log = logger.scope("home-quick-action"); +export interface RunWorkstreamAction { + run: (action: BoundAction, workstream: HomeWorkstream) => void; +} + // The agent runs the bound skill when the prompt starts with `/`, so // embed it directly; the descriptive prompt follows as the instruction. With no // skill bound, send the prompt on its own. @@ -41,10 +48,14 @@ function buildSkillPrompt(action: BoundAction): string { /** * Runs a bound workflow action as a one-click cloud task: embeds the skill as a * `/` prefix and starts a cloud run on the workstream's repo + branch. - * Falls back to the new-task screen (prompt prefilled) when it can't start - * cleanly — offline, signed out, or the repo has no GitHub integration. + * Stays on Home — the new task is spliced into the workstream's task list + * optimistically and `isPending` disables the trigger while it starts. Falls + * back to the new-task screen (prompt prefilled) when it can't start cleanly — + * offline, signed out, or the repo has no GitHub integration. */ -export function useRunWorkstreamAction() { +export function useRunWorkstreamAction(): RunWorkstreamAction { + // Shared, workstream-keyed in-flight state so the row and the open detail panel + // (independent hook instances) can't both start a task for the same workstream. const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -56,9 +67,14 @@ export function useRunWorkstreamAction() { const lastUsedModel = useSettingsStore((s) => s.lastUsedModel); const taskService = useService(TASK_SERVICE); const modelResolver = useService(REPORT_MODEL_RESOLVER); - const inFlightRef = useRef(false); + const queryClient = useQueryClient(); + // Fire-and-forget nudge so the server worker rebuilds the snapshot sooner; the + // optimistic splice covers the gap until the next poll reconciles. + const refreshHome = useAuthenticatedMutation((client) => + client.refreshHomeSnapshot(), + ); - return useCallback( + const run = useCallback( (action: BoundAction, workstream: HomeWorkstream) => { const promptText = buildSkillPrompt(action); // The GitHub integration map and cloud repo selector are keyed by the full @@ -85,16 +101,9 @@ export function useRunWorkstreamAction() { return; } - if (inFlightRef.current) return; - inFlightRef.current = true; - - const pendingTaskKey = - globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`; - pendingTaskPromptStoreApi.set(pendingTaskKey, { - promptText, - attachments: [], - }); - navigateToTaskPending(pendingTaskKey); + const quickActions = useQuickActionStore.getState(); + if (quickActions.inFlight[workstream.id]) return; + quickActions.start(workstream.id); void (async () => { try { @@ -109,7 +118,6 @@ export function useRunWorkstreamAction() { ); } if (!model) { - pendingTaskPromptStoreApi.clear(pendingTaskKey); toast.error("Couldn't start task", { description: "No model is configured. Pick a model for this quick action.", @@ -129,12 +137,25 @@ export function useRunWorkstreamAction() { githubUserIntegrationId, adapter, model, + homeQuickActionLabel: action.label, }; const result = await taskService.createTask(input, (output) => { + // Stay on Home: refresh the task caches and splice the new run into + // this workstream's list so it shows up immediately (tagged with the + // quick action), then let the server worker reconcile on the next poll. invalidateTasks(output.task); - pendingTaskPromptStoreApi.move(pendingTaskKey, output.task.id); - void openTask(output.task); + queryClient.setQueryData(homeKeys.snapshot, (old) => + old + ? insertOptimisticTask( + old, + workstream.id, + output.task, + action.label, + ) + : old, + ); + void refreshHome.mutateAsync().catch(() => {}); }); if (result.success) { @@ -149,7 +170,6 @@ export function useRunWorkstreamAction() { }); return; } - pendingTaskPromptStoreApi.clear(pendingTaskKey); toast.error("Failed to start task", { description: result.error }); log.error("Quick action task creation failed", { failedStep: result.failedStep, @@ -157,14 +177,13 @@ export function useRunWorkstreamAction() { }); fallbackToTaskInput(); } catch (error) { - pendingTaskPromptStoreApi.clear(pendingTaskKey); const description = error instanceof Error ? error.message : "Unknown error"; toast.error("Failed to start task", { description }); log.error("Quick action task creation threw", { error }); fallbackToTaskInput(); } finally { - inFlightRef.current = false; + useQuickActionStore.getState().finish(workstream.id); } })(); }, @@ -173,6 +192,8 @@ export function useRunWorkstreamAction() { isOnline, cloudRegion, invalidateTasks, + queryClient, + refreshHome.mutateAsync, getUserIntegrationIdForRepo, lastUsedAdapter, lastUsedModel, @@ -180,4 +201,6 @@ export function useRunWorkstreamAction() { modelResolver, ], ); + + return { run }; } diff --git a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts index 82a6d46c3..3e20ce692 100644 --- a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts +++ b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts @@ -6,6 +6,7 @@ import { useBoundActions, } from "@posthog/ui/features/home/hooks/useBoundActions"; import { useRunWorkstreamAction } from "@posthog/ui/features/home/hooks/useRunWorkstreamAction"; +import { useQuickActionStore } from "@posthog/ui/features/home/stores/quickActionStore"; import { SITUATION_VISUAL, type SituationCss, @@ -27,6 +28,8 @@ export interface WorkstreamPresentation { generating: boolean; /** A task in this workstream is blocked awaiting a permission response. */ needsPermission: boolean; + /** Distinct quick-action labels that have been run against this workstream, newest first. */ + quickActions: string[]; primaryBound: BoundAction | null; restBound: BoundAction[]; primaryIsPr: boolean; @@ -35,6 +38,8 @@ export interface WorkstreamPresentation { showTaskInMenu: boolean; hasMenu: boolean; runAction: (action: BoundAction) => void; + /** True while a quick action is starting a task; disable the row's action controls. */ + isRunningAction: boolean; openTask: () => void; openPr: () => void; } @@ -48,7 +53,10 @@ export function useWorkstreamPresentation( ): WorkstreamPresentation { const { data: tasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const run = useRunWorkstreamAction(); + const { run } = useRunWorkstreamAction(); + const isRunningAction = useQuickActionStore( + (s) => !!s.inFlight[workstream.id], + ); const pr = workstream.pr; const headTask = workstream.tasks[0]; @@ -62,6 +70,13 @@ export function useWorkstreamPresentation( ); const generating = workstream.tasks.some((t) => t.isGenerating); const needsPermission = workstream.tasks.some((t) => t.needsPermission); + const quickActions = [ + ...new Set( + workstream.tasks + .map((t) => t.quickAction) + .filter((label): label is string => !!label), + ), + ]; const primaryBound = boundActions[0] ?? null; const restBound = primaryBound ? boundActions.slice(1) : []; @@ -81,6 +96,7 @@ export function useWorkstreamPresentation( extraSituations, generating, needsPermission, + quickActions, primaryBound, restBound, primaryIsPr, @@ -89,6 +105,7 @@ export function useWorkstreamPresentation( showTaskInMenu, hasMenu, runAction: (action) => run(action, workstream), + isRunningAction, openTask: () => { if (!headTask) return; const task = tasks.find((t) => t.id === headTask.id); diff --git a/packages/ui/src/features/home/stores/quickActionStore.test.ts b/packages/ui/src/features/home/stores/quickActionStore.test.ts new file mode 100644 index 000000000..d51675fb8 --- /dev/null +++ b/packages/ui/src/features/home/stores/quickActionStore.test.ts @@ -0,0 +1,30 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { useQuickActionStore } from "./quickActionStore"; + +describe("quickActionStore", () => { + beforeEach(() => { + useQuickActionStore.setState({ inFlight: {} }); + }); + + it("marks a workstream in flight and clears it", () => { + const { start, finish } = useQuickActionStore.getState(); + + start("ws_1"); + expect(useQuickActionStore.getState().inFlight.ws_1).toBe(true); + + finish("ws_1"); + expect(useQuickActionStore.getState().inFlight.ws_1).toBeUndefined(); + }); + + it("tracks workstreams independently so distinct ones can run concurrently", () => { + const { start, finish } = useQuickActionStore.getState(); + + start("ws_1"); + start("ws_2"); + finish("ws_1"); + + const { inFlight } = useQuickActionStore.getState(); + expect(inFlight.ws_1).toBeUndefined(); + expect(inFlight.ws_2).toBe(true); + }); +}); diff --git a/packages/ui/src/features/home/stores/quickActionStore.ts b/packages/ui/src/features/home/stores/quickActionStore.ts new file mode 100644 index 000000000..e89b4938c --- /dev/null +++ b/packages/ui/src/features/home/stores/quickActionStore.ts @@ -0,0 +1,24 @@ +import { create } from "zustand"; + +// Tracks which workstreams have a quick action in flight, keyed by workstream id. +// Shared across every surface that can start an action (the list/board row and the +// detail panel mount independent `useRunWorkstreamAction` hooks), so the guard and +// the disabled state are consistent across all of them — a per-hook ref would let +// the row and the open detail panel each start a task for the same workstream. +interface QuickActionStore { + inFlight: Record; + start: (workstreamId: string) => void; + finish: (workstreamId: string) => void; +} + +export const useQuickActionStore = create((set) => ({ + inFlight: {}, + start: (workstreamId) => + set((s) => ({ inFlight: { ...s.inFlight, [workstreamId]: true } })), + finish: (workstreamId) => + set((s) => { + const next = { ...s.inFlight }; + delete next[workstreamId]; + return { inFlight: next }; + }), +})); diff --git a/packages/ui/src/features/home/utils/optimisticTask.test.ts b/packages/ui/src/features/home/utils/optimisticTask.test.ts new file mode 100644 index 000000000..a188f61a2 --- /dev/null +++ b/packages/ui/src/features/home/utils/optimisticTask.test.ts @@ -0,0 +1,176 @@ +import type { HomeSnapshot, HomeWorkstream } from "@posthog/core/home/schemas"; +import type { Task } from "@posthog/shared/domain-types"; +import { describe, expect, it } from "vitest"; +import { insertOptimisticTask, workstreamTaskFromTask } from "./optimisticTask"; + +function makeWs(overrides: Partial = {}): HomeWorkstream { + return { + id: "ws_1", + repoName: null, + repoFullPath: null, + branch: null, + prUrl: null, + pr: null, + tasks: [], + situations: [], + primarySituation: null, + lastActivityAt: 0, + ...overrides, + }; +} + +function makeTask(overrides: Partial = {}): Task { + return { + id: "task_1", + task_number: 1, + slug: "T-1", + title: "Fix CI", + description: "", + created_at: "", + updated_at: "", + origin_product: "user_created", + ...overrides, + }; +} + +function makeSnapshot(overrides: Partial = {}): HomeSnapshot { + return { activeAgents: [], needsAttention: [], inProgress: [], ...overrides }; +} + +describe("workstreamTaskFromTask", () => { + it.each([ + { + name: "provisional queued task with no quick action", + task: makeTask(), + quickAction: undefined, + expected: { + id: "task_1", + title: "Fix CI", + status: "queued", + isGenerating: false, + needsPermission: false, + quickAction: null, + }, + }, + { + name: "records the quick action label when provided", + task: makeTask(), + quickAction: "Fix CI", + expected: { + id: "task_1", + title: "Fix CI", + status: "queued", + isGenerating: false, + needsPermission: false, + quickAction: "Fix CI", + }, + }, + { + name: "prefers the latest run status when present", + task: makeTask({ + latest_run: { status: "in_progress" } as Task["latest_run"], + }), + quickAction: undefined, + expected: { + id: "task_1", + title: "Fix CI", + status: "in_progress", + isGenerating: false, + needsPermission: false, + quickAction: null, + }, + }, + { + name: "falls back to a placeholder title", + task: makeTask({ title: "" }), + quickAction: undefined, + expected: { + id: "task_1", + title: "New task", + status: "queued", + isGenerating: false, + needsPermission: false, + quickAction: null, + }, + }, + ])("$name", ({ task, quickAction, expected }) => { + expect(workstreamTaskFromTask(task, quickAction)).toEqual(expected); + }); +}); + +describe("insertOptimisticTask", () => { + it("prepends the task to the matching workstream", () => { + const snapshot = makeSnapshot({ + inProgress: [ + makeWs({ + id: "ws_1", + tasks: [ + { + id: "old", + title: "Old", + status: "completed", + isGenerating: false, + needsPermission: false, + }, + ], + }), + ], + }); + + const next = insertOptimisticTask(snapshot, "ws_1", makeTask()); + + expect(next.inProgress[0].tasks.map((t) => t.id)).toEqual([ + "task_1", + "old", + ]); + }); + + it("matches workstreams in either bucket", () => { + const snapshot = makeSnapshot({ + needsAttention: [makeWs({ id: "ws_attn" })], + }); + const next = insertOptimisticTask(snapshot, "ws_attn", makeTask()); + expect(next.needsAttention[0].tasks.map((t) => t.id)).toEqual(["task_1"]); + }); + + it("leaves other workstreams untouched", () => { + const other = makeWs({ id: "ws_2" }); + const snapshot = makeSnapshot({ + inProgress: [makeWs({ id: "ws_1" }), other], + }); + + const next = insertOptimisticTask(snapshot, "ws_1", makeTask()); + + expect(next.inProgress[1]).toBe(other); + expect(next.inProgress[0].tasks.map((t) => t.id)).toEqual(["task_1"]); + }); + + it("tags the spliced task with the quick action label", () => { + const snapshot = makeSnapshot({ inProgress: [makeWs({ id: "ws_1" })] }); + const next = insertOptimisticTask(snapshot, "ws_1", makeTask(), "Fix CI"); + expect(next.inProgress[0].tasks[0].quickAction).toBe("Fix CI"); + }); + + it("does not duplicate a task that is already present", () => { + const snapshot = makeSnapshot({ + inProgress: [ + makeWs({ + id: "ws_1", + tasks: [ + { + id: "task_1", + title: "Fix CI", + status: "queued", + isGenerating: false, + needsPermission: false, + }, + ], + }), + ], + }); + + const next = insertOptimisticTask(snapshot, "ws_1", makeTask()); + + expect(next.inProgress[0].tasks).toHaveLength(1); + }); +}); diff --git a/packages/ui/src/features/home/utils/optimisticTask.ts b/packages/ui/src/features/home/utils/optimisticTask.ts new file mode 100644 index 000000000..b91d2f7d1 --- /dev/null +++ b/packages/ui/src/features/home/utils/optimisticTask.ts @@ -0,0 +1,45 @@ +import type { + HomeSnapshot, + HomeWorkstream, + HomeWorkstreamTask, +} from "@posthog/core/home/schemas"; +import type { Task } from "@posthog/shared/domain-types"; + +// A freshly-created cloud task hasn't been picked up by the server-side workstream rebuild yet, +// so we splice a provisional row in by hand to give the quick action immediate feedback. The +// next snapshot poll reconciles it with the authoritative server state. +export function workstreamTaskFromTask( + task: Task, + quickAction?: string, +): HomeWorkstreamTask { + return { + id: task.id, + title: task.title || "New task", + status: task.latest_run?.status ?? "queued", + isGenerating: false, + needsPermission: false, + quickAction: quickAction ?? null, + }; +} + +export function insertOptimisticTask( + snapshot: HomeSnapshot, + workstreamId: string, + task: Task, + quickAction?: string, +): HomeSnapshot { + const wsTask = workstreamTaskFromTask(task, quickAction); + + const addToBucket = (bucket: HomeWorkstream[]): HomeWorkstream[] => + bucket.map((ws) => + ws.id === workstreamId && !ws.tasks.some((t) => t.id === task.id) + ? { ...ws, tasks: [wsTask, ...ws.tasks] } + : ws, + ); + + return { + ...snapshot, + needsAttention: addToBucket(snapshot.needsAttention), + inProgress: addToBucket(snapshot.inProgress), + }; +}