From 8cc8654bc693f38086f7c71bb8712be88d520523 Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 11 Jun 2026 13:37:58 +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 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..7bd47670a 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 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..1b0497468 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 82a6d46c3..d2f8e14b5 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 000000000..7532313c2 --- /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 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), + }; +} From f51118a6a17efc1c8cd4a66cfffc38f6fe24781b Mon Sep 17 00:00:00 2001 From: Peter Kirkham Date: Thu, 11 Jun 2026 14:07:30 +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 7bd47670a..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,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 1b0497468..1887fadb3 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 d2f8e14b5..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, @@ -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 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 index 7532313c2..a188f61a2 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); }); });