From b58775f2d5571e0f78f7b992bea38f1965ce564b Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sat, 20 Jun 2026 06:50:26 +0100 Subject: [PATCH 1/2] fix(home): run quick actions inline with optimistic task insert Quick actions on a workstream row/card/detail panel no longer navigate away. The action starts the cloud task in place, optimistically splices the new run into the workstream's task list (tagged with the action label), disables the trigger with a spinner while in flight, and nudges a server snapshot refresh so the next poll reconciles. Threads the quick-action label end-to-end (TaskCreationInput -> saga -> api-client body -> home_quick_action) and renders it: a per-task chip in the detail panel and a glanceable indicator on the row showing which quick actions have run against the workstream. Generated-By: PostHog Code Task-Id: 8dc7acb2-b80c-402c-be8c-5a82ea04a68f --- packages/api-client/src/posthog-client.ts | 4 + packages/core/src/home/schemas.ts | 3 + .../src/task-detail/taskCreationApiClient.ts | 1 + .../core/src/task-detail/taskCreationSaga.ts | 1 + packages/shared/src/task-creation-domain.ts | 3 + .../home/components/HomeWorkstreamCard.tsx | 10 +- .../components/HomeWorkstreamDetailPanel.tsx | 20 ++- .../home/components/HomeWorkstreamRow.tsx | 26 ++- .../home/components/WorkstreamBits.tsx | 4 + .../home/hooks/useRunWorkstreamAction.ts | 69 +++++--- .../home/hooks/useWorkstreamPresentation.ts | 15 +- .../home/utils/optimisticTask.test.ts | 148 ++++++++++++++++++ .../src/features/home/utils/optimisticTask.ts | 45 ++++++ 13 files changed, 322 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/features/home/utils/optimisticTask.test.ts create mode 100644 packages/ui/src/features/home/utils/optimisticTask.ts diff --git a/packages/api-client/src/posthog-client.ts b/packages/api-client/src/posthog-client.ts index d427ae83be..c410732621 100644 --- a/packages/api-client/src/posthog-client.ts +++ b/packages/api-client/src/posthog-client.ts @@ -408,6 +408,7 @@ interface CloudRunOptions { runSource?: CloudRunSource; signalReportId?: string; initialPermissionMode?: PermissionMode; + homeQuickAction?: string; } interface CreateTaskRunOptions extends CloudRunOptions { @@ -486,6 +487,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 71fac7ee82..0ab439eb53 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 7c00dea6be..3ef2f5ed56 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 174d510efc..6ed15ec505 100644 --- a/packages/core/src/task-detail/taskCreationSaga.ts +++ b/packages/core/src/task-detail/taskCreationSaga.ts @@ -281,6 +281,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 a07e08eb66..93001c0d53 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -50,6 +50,9 @@ export interface TaskCreationInput { * working directory, so non-code tasks (analysis, email) can run repo-less. */ allowNoRepo?: boolean; + // 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 d0105a7848..913f3c6091 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 821b187556..7bd47670ae 100644 --- a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx +++ b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx @@ -41,7 +41,8 @@ interface Props { export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { const { data: allTasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const runAction = useRunWorkstreamAction(); + const { run: runAction, isPending: isRunningAction } = + useRunWorkstreamAction(); const pr = workstream.pr; const headTask = workstream.tasks[0]; @@ -124,10 +125,15 @@ export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { ) : null} @@ -143,6 +149,7 @@ export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { {overflowActions.map((action: BoundAction) => ( runAction(action, workstream)} > @@ -306,6 +313,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 097fde186c..26c50ecef0 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 618d5ec384..bd408c5153 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 8eacc466fb..1b04974683 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,29 @@ import { type TaskCreationInput, } from "@posthog/shared"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; +import { homeKeys } from "@posthog/ui/features/home/hooks/useHomeSnapshot"; +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, useRef, useState } from "react"; import type { BoundAction } from "./useBoundActions"; const log = logger.scope("home-quick-action"); +export interface RunWorkstreamAction { + run: (action: BoundAction, workstream: HomeWorkstream) => void; + /** True while a one-click task is being created, so callers can disable the trigger. */ + isPending: boolean; +} + // 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 +49,12 @@ 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 { const isAuthenticated = useAuthStateValue( (state) => state.status === "authenticated", ); @@ -56,9 +66,16 @@ export function useRunWorkstreamAction() { const lastUsedModel = useSettingsStore((s) => s.lastUsedModel); const taskService = useService(TASK_SERVICE); const modelResolver = useService(REPORT_MODEL_RESOLVER); + 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(), + ); const inFlightRef = useRef(false); + const [isPending, setIsPending] = useState(false); - 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 @@ -87,14 +104,7 @@ export function useRunWorkstreamAction() { if (inFlightRef.current) return; inFlightRef.current = true; - - const pendingTaskKey = - globalThis.crypto?.randomUUID?.() ?? `pending-${Date.now()}`; - pendingTaskPromptStoreApi.set(pendingTaskKey, { - promptText, - attachments: [], - }); - navigateToTaskPending(pendingTaskKey); + setIsPending(true); void (async () => { try { @@ -109,7 +119,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 +138,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 +171,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,7 +178,6 @@ 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 }); @@ -165,6 +185,7 @@ export function useRunWorkstreamAction() { fallbackToTaskInput(); } finally { inFlightRef.current = false; + setIsPending(false); } })(); }, @@ -173,6 +194,8 @@ export function useRunWorkstreamAction() { isOnline, cloudRegion, invalidateTasks, + queryClient, + refreshHome, getUserIntegrationIdForRepo, lastUsedAdapter, lastUsedModel, @@ -180,4 +203,6 @@ export function useRunWorkstreamAction() { modelResolver, ], ); + + return { run, isPending }; } diff --git a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts index 82a6d46c39..d2f8e14b53 100644 --- a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts +++ b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts @@ -27,6 +27,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 +37,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 +52,7 @@ export function useWorkstreamPresentation( ): WorkstreamPresentation { const { data: tasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const run = useRunWorkstreamAction(); + const { run, isPending: isRunningAction } = useRunWorkstreamAction(); const pr = workstream.pr; const headTask = workstream.tasks[0]; @@ -62,6 +66,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 +92,7 @@ export function useWorkstreamPresentation( extraSituations, generating, needsPermission, + quickActions, primaryBound, restBound, primaryIsPr, @@ -89,6 +101,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/utils/optimisticTask.test.ts b/packages/ui/src/features/home/utils/optimisticTask.test.ts new file mode 100644 index 0000000000..7532313c27 --- /dev/null +++ b/packages/ui/src/features/home/utils/optimisticTask.test.ts @@ -0,0 +1,148 @@ +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("maps a created task to a provisional queued workstream task", () => { + const wsTask = workstreamTaskFromTask(makeTask()); + expect(wsTask).toEqual({ + id: "task_1", + title: "Fix CI", + status: "queued", + isGenerating: false, + needsPermission: false, + quickAction: null, + }); + }); + + it("records the quick action label when provided", () => { + expect(workstreamTaskFromTask(makeTask(), "Fix CI").quickAction).toBe( + "Fix CI", + ); + }); + + it("prefers the latest run status when present", () => { + const wsTask = workstreamTaskFromTask( + makeTask({ latest_run: { status: "in_progress" } as Task["latest_run"] }), + ); + expect(wsTask.status).toBe("in_progress"); + }); + + it("falls back to a placeholder title", () => { + expect(workstreamTaskFromTask(makeTask({ title: "" })).title).toBe( + "New task", + ); + }); +}); + +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 0000000000..b91d2f7d13 --- /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), + }; +} From 68799a06ee71aa5ad65d8c9f2001c748b9297d5f Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Sat, 20 Jun 2026 06:50:28 +0100 Subject: [PATCH 2/2] fix(home): share quick-action in-flight state across surfaces Addresses Greptile review on PR #2601. - Move the quick-action in-flight guard from a per-hook ref/state into a shared, workstream-keyed Zustand store. The list/board row and the open detail panel mount independent useRunWorkstreamAction hooks, so the previous per-instance guard let both start a task for the same workstream. The store keys by workstream id, so the same workstream can't double-submit while distinct workstreams still run concurrently. - Depend on refreshHome.mutateAsync (stable in react-query v5) instead of the whole mutation object, so run isn't recreated on every mutation transition. - Parameterise the workstreamTaskFromTask tests with it.each per repo convention. Generated-By: PostHog Code Task-Id: 8dc7acb2-b80c-402c-be8c-5a82ea04a68f --- .../components/HomeWorkstreamDetailPanel.tsx | 7 +- .../home/hooks/useRunWorkstreamAction.ts | 22 +++-- .../home/hooks/useWorkstreamPresentation.ts | 6 +- .../home/stores/quickActionStore.test.ts | 30 +++++++ .../features/home/stores/quickActionStore.ts | 24 ++++++ .../home/utils/optimisticTask.test.ts | 86 ++++++++++++------- 6 files changed, 131 insertions(+), 44 deletions(-) create mode 100644 packages/ui/src/features/home/stores/quickActionStore.test.ts create mode 100644 packages/ui/src/features/home/stores/quickActionStore.ts diff --git a/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx b/packages/ui/src/features/home/components/HomeWorkstreamDetailPanel.tsx index 7bd47670ae..71743b3109 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,8 +42,10 @@ interface Props { export function HomeWorkstreamDetailPanel({ workstream, onClose }: Props) { const { data: allTasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const { run: runAction, isPending: isRunningAction } = - useRunWorkstreamAction(); + const { run: runAction } = useRunWorkstreamAction(); + const isRunningAction = useQuickActionStore( + (s) => !!s.inFlight[workstream.id], + ); const pr = workstream.pr; const headTask = workstream.tasks[0]; diff --git a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts index 1b04974683..1887fadb34 100644 --- a/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts +++ b/packages/ui/src/features/home/hooks/useRunWorkstreamAction.ts @@ -13,6 +13,7 @@ import { } 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"; @@ -24,15 +25,13 @@ import { openTaskInput } from "@posthog/ui/router/useOpenTask"; import { track } from "@posthog/ui/shell/analytics"; import { logger } from "@posthog/ui/shell/logger"; import { useQueryClient } from "@tanstack/react-query"; -import { useCallback, useRef, useState } from "react"; +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; - /** True while a one-click task is being created, so callers can disable the trigger. */ - isPending: boolean; } // The agent runs the bound skill when the prompt starts with `/`, so @@ -55,6 +54,8 @@ function buildSkillPrompt(action: BoundAction): string { * offline, signed out, or the repo has no GitHub integration. */ 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", ); @@ -72,8 +73,6 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { const refreshHome = useAuthenticatedMutation((client) => client.refreshHomeSnapshot(), ); - const inFlightRef = useRef(false); - const [isPending, setIsPending] = useState(false); const run = useCallback( (action: BoundAction, workstream: HomeWorkstream) => { @@ -102,9 +101,9 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { return; } - if (inFlightRef.current) return; - inFlightRef.current = true; - setIsPending(true); + const quickActions = useQuickActionStore.getState(); + if (quickActions.inFlight[workstream.id]) return; + quickActions.start(workstream.id); void (async () => { try { @@ -184,8 +183,7 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { log.error("Quick action task creation threw", { error }); fallbackToTaskInput(); } finally { - inFlightRef.current = false; - setIsPending(false); + useQuickActionStore.getState().finish(workstream.id); } })(); }, @@ -195,7 +193,7 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { cloudRegion, invalidateTasks, queryClient, - refreshHome, + refreshHome.mutateAsync, getUserIntegrationIdForRepo, lastUsedAdapter, lastUsedModel, @@ -204,5 +202,5 @@ export function useRunWorkstreamAction(): RunWorkstreamAction { ], ); - return { run, isPending }; + return { run }; } diff --git a/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts b/packages/ui/src/features/home/hooks/useWorkstreamPresentation.ts index d2f8e14b53..3e20ce6923 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, @@ -52,7 +53,10 @@ export function useWorkstreamPresentation( ): WorkstreamPresentation { const { data: tasks = [] } = useTasks(); const boundActions = useBoundActions(workstream); - const { run, isPending: isRunningAction } = useRunWorkstreamAction(); + const { run } = useRunWorkstreamAction(); + const isRunningAction = useQuickActionStore( + (s) => !!s.inFlight[workstream.id], + ); const pr = workstream.pr; const headTask = workstream.tasks[0]; 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 0000000000..d51675fb85 --- /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 0000000000..e89b4938c4 --- /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 index 7532313c27..a188f61a29 100644 --- a/packages/ui/src/features/home/utils/optimisticTask.test.ts +++ b/packages/ui/src/features/home/utils/optimisticTask.test.ts @@ -38,35 +38,63 @@ function makeSnapshot(overrides: Partial = {}): HomeSnapshot { } describe("workstreamTaskFromTask", () => { - it("maps a created task to a provisional queued workstream task", () => { - const wsTask = workstreamTaskFromTask(makeTask()); - expect(wsTask).toEqual({ - id: "task_1", - title: "Fix CI", - status: "queued", - isGenerating: false, - needsPermission: false, - quickAction: null, - }); - }); - - it("records the quick action label when provided", () => { - expect(workstreamTaskFromTask(makeTask(), "Fix CI").quickAction).toBe( - "Fix CI", - ); - }); - - it("prefers the latest run status when present", () => { - const wsTask = workstreamTaskFromTask( - makeTask({ latest_run: { status: "in_progress" } as Task["latest_run"] }), - ); - expect(wsTask.status).toBe("in_progress"); - }); - - it("falls back to a placeholder title", () => { - expect(workstreamTaskFromTask(makeTask({ title: "" })).title).toBe( - "New task", - ); + 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); }); });