From da17a57bde8550dd796e2118896a7b5c349f59e9 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sun, 2 Aug 2026 19:38:35 -0700 Subject: [PATCH] FN-8731: refresh task definition prompts during planning Keep the visible task definition current while planning and Plan Review can revise it. - Refresh Definition prompts when the visible tab opens and poll during active planning lifecycles. - Preserve inline edit drafts while applying authoritative prompt updates. - Add lifecycle and hidden-host coverage, documentation, and a patch changeset. Files changed: .changeset/fn-8731-definition-prompt-refresh.md | 7 ++ docs/dashboard-guide.md | 1 + .../dashboard/app/components/TaskDetailModal.tsx | 106 ++++++++++++++++++--- .../TaskDetailModal.definition-actions.test.tsx | 91 +++++++++++++++++- .../TaskDetailModal.popup-hidden-gating.test.tsx | 25 ++++- 5 files changed, 215 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-8731 Fusion-Task-Lineage: b5536ad7-cbe1-42ee-a6df-6e997eeeda05 Co-authored-by: Fusion (runfusion.ai) --- .../fn-8731-definition-prompt-refresh.md | 7 ++ docs/dashboard-guide.md | 1 + .../app/components/TaskDetailModal.tsx | 106 ++++++++++++++++-- ...askDetailModal.definition-actions.test.tsx | 91 ++++++++++++++- ...skDetailModal.popup-hidden-gating.test.tsx | 25 ++++- 5 files changed, 215 insertions(+), 15 deletions(-) create mode 100644 .changeset/fn-8731-definition-prompt-refresh.md diff --git a/.changeset/fn-8731-definition-prompt-refresh.md b/.changeset/fn-8731-definition-prompt-refresh.md new file mode 100644 index 0000000000..93da08a879 --- /dev/null +++ b/.changeset/fn-8731-definition-prompt-refresh.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep Task Detail Plan content current while planning and Plan Review run. +category: fix +dev: Refreshes the visible Definition/Plan prompt through the scoped task-detail endpoint. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index a02b14d884..5f22045e41 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -1498,6 +1498,7 @@ Inspect task definition, logs, review feedback, comments, artifacts, workflow ou - In shared task edit/create forms, GitHub Tracking appears at the bottom of **More options**, after **Workflow Steps**. In task edit mode, **Workflow Steps** appears only when the task's resolved workflow exposes optional steps, so workflows without optional steps do not leave an empty button shell. - From this section you can explicitly enable/disable tracking and manage a per-task repo override (`owner/repo`). Clearing the override saves `null` and falls back to project/global defaults. - The **Plan** tab shows the stored **Original prompt** above the generated `PROMPT.md` content, so the exact task prompt remains visible after planning. It is collapsed by default behind a chevron toggle; expanding it renders the prompt as Markdown (the same renderer used for the generated plan body). It stays read-only — editing or requesting AI revision still applies only to the generated plan. +- The **Plan** tab refreshes its generated `PROMPT.md` immediately whenever it is shown or re-shown. While that visible detail is actively planning, replanning, or running Plan Review, it polls for newer prompt revisions; polling stops when the tab or host is hidden, the lifecycle exits those paths, or the task/project changes. An active inline edit keeps its local draft until the operator saves or cancels it. - In `in-review`, pull-request controls/status (including stall badges) are in a dedicated **Pull Request** tab instead of the Definition tab. - In the task detail **Pull Request** tab, PR numbers open the linked pull request on GitHub when a PR URL is available. - Task Detail and list split-pane PR affordances follow the live project auto-merge setting: when auto-merge is off, manual **Create PR** / merge actions are shown; when it is on, the tab shows the automatic auto-merge hint unless a per-task override changes the effective behavior. diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx index 2959ad1470..db71e02310 100644 --- a/packages/dashboard/app/components/TaskDetailModal.tsx +++ b/packages/dashboard/app/components/TaskDetailModal.tsx @@ -102,6 +102,17 @@ const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160; const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120; const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320; const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350; +const PROMPT_REFRESH_INTERVAL_MS = 5_000; + +function isPromptRefreshLifecycleActive(task: Pick): boolean { + if (task.status === "planning" || task.status === "needs-replan") return true; + return task.workflowStepResults?.some((result) => + (result.workflowStepId === "plan-review" || result.workflowStepId === "plan-replan") + && result.startedAt != null + && result.completedAt == null, + ) ?? false; +} + // FNXC:TaskDetailSwipeBack 2026-07-05-12:30: FN-7587 — mobile-mode gating the presentation-only predictive-back slide/fade transition on the modal/list/nested task-detail surface uses the shared viewport classifier, so known 768px tablets do not receive phone-only presentation. // FNXC:PlannerOversight 2026-07-05-00:00: FN-7604 — the OVERSIGHT_MENU_MOBILE_BREAKPOINT constant (formerly used to branch the oversight controls between an inline cluster and this overflow menu) was removed; the overflow-menu dropdown is now the single universal surface at every viewport, so no breakpoint gates it. @@ -828,6 +839,27 @@ export function TaskDetailContent({ !("prompt" in task), ); const [verificationRequest, setVerificationRequest] = useState(null); + const detailRequestGenerationRef = useRef(0); + const detailRequestRef = useRef<{ key: string; promise: Promise } | null>(null); + + /* + FNXC:TaskDetailPlan 2026-08-03-02:24: + A slim task can need its initial detail and its visible Definition refresh in the same commit. + Share that project-scoped request so opening Definition produces one authoritative fetch rather + than invalidating the initial load and issuing duplicate traffic. + */ + const requestTaskDetail = useCallback((taskId: string, requestProjectId?: string) => { + const key = `${requestProjectId ?? ""}:${taskId}`; + if (detailRequestRef.current?.key === key) return detailRequestRef.current.promise; + + const promise = fetchTaskDetail(taskId, requestProjectId); + detailRequestRef.current = { key, promise }; + void promise.then( + () => { if (detailRequestRef.current?.promise === promise) detailRequestRef.current = null; }, + () => { if (detailRequestRef.current?.promise === promise) detailRequestRef.current = null; }, + ); + return promise; + }, []); /* FNXC:TaskPopupViewGating 2026-07-23-10:20: @@ -848,6 +880,8 @@ export function TaskDetailContent({ }, [task.id, projectId, active]); useEffect(() => { + // FNXC:TaskDetailPlan 2026-08-03-02:06: hidden kept-alive hosts defer their initial detail request until reveal. + if (!active) return; // If the prop already has a prompt field, it's a full TaskDetail if ("prompt" in task) { setFullDetail(task as TaskDetail); @@ -856,24 +890,25 @@ export function TaskDetailContent({ } let cancelled = false; + const requestGeneration = ++detailRequestGenerationRef.current; setDetailLoading(true); setFullDetail(null); - fetchTaskDetail(task.id, projectId) + requestTaskDetail(task.id, projectId) .then((detail) => { - if (!cancelled) { + if (!cancelled && detailRequestGenerationRef.current === requestGeneration) { setFullDetail(detail); setDetailLoading(false); } }) .catch(() => { - if (!cancelled) { + if (!cancelled && detailRequestGenerationRef.current === requestGeneration) { setDetailLoading(false); } }); return () => { cancelled = true; }; - }, [task.id, projectId]); + }, [task.id, projectId, active, requestTaskDetail]); // Derive a working task that always has all available fields. // Falls back to the optimistic Task while loading, uses fullDetail once loaded. @@ -1167,6 +1202,52 @@ export function TaskDetailContent({ const [specEditContent, setSpecEditContent] = useState(workingTask.prompt || ""); const [specFeedback, setSpecFeedback] = useState(""); const [showRefineModal, setShowRefineModal] = useState(false); + + /* + FNXC:TaskDetailPlan 2026-08-03-02:06: + Definition is the authoritative PROMPT.md view while planning or graph Plan Review may rewrite it. + Refresh on every visible show/re-show, then keep one bounded chain only for planning, replan, or a + running plan-review gate. The request generation prevents a late task/project response from + replacing current detail, and intentionally updates only the authoritative prompt so active edits + retain their local textarea buffer. + */ + const promptRefreshLifecycleActive = isPromptRefreshLifecycleActive(task); + useEffect(() => { + if (!active || activeTab !== "definition") return; + + let cancelled = false; + let inFlight = false; + const requestGeneration = ++detailRequestGenerationRef.current; + const refreshPrompt = () => { + if (inFlight) return; + inFlight = true; + void requestTaskDetail(task.id, projectId) + .then((detail) => { + if (cancelled || detailRequestGenerationRef.current !== requestGeneration || detail.id !== task.id) return; + setFullDetail((previous) => previous ? { ...previous, prompt: detail.prompt } : detail); + setDetailLoading(false); + }) + .catch(() => { + // FNXC:TaskDetailPlan 2026-08-03-02:06: retain the last good prompt; a later eligible tick may recover. + }) + .finally(() => { inFlight = false; }); + }; + + refreshPrompt(); + if (!promptRefreshLifecycleActive) { + return () => { + cancelled = true; + if (detailRequestGenerationRef.current === requestGeneration) detailRequestGenerationRef.current++; + }; + } + + const timer = window.setInterval(refreshPrompt, PROMPT_REFRESH_INTERVAL_MS); + return () => { + cancelled = true; + window.clearInterval(timer); + if (detailRequestGenerationRef.current === requestGeneration) detailRequestGenerationRef.current++; + }; + }, [active, activeTab, projectId, promptRefreshLifecycleActive, requestTaskDetail, task.id]); const [prCreateOpen, setPrCreateOpen] = useState(false); useLayoutEffect(() => { @@ -1956,12 +2037,17 @@ export function TaskDetailContent({ return () => document.removeEventListener("keydown", handleKeyDown); }, [showMoveMenu, showActionsMenu, showActivityViewMenu, showOversightMenu, showInlinePriorityPicker]); - // Reset spec edit state when task changes + /* + FNXC:TaskDetailPlan 2026-08-03-02:32: + A visible Definition poll may update the authoritative prompt while an operator is editing it. + Reset edit state only for a different task; reacting to prompt revisions would discard the active + local draft and replace its textarea. + */ useEffect(() => { setIsEditingSpec(false); setSpecEditContent(workingTask.prompt || ""); setSpecFeedback(""); - }, [task.id, workingTask.prompt]); + }, [task.id]); // Note: TaskForm handles auto-focus internally via isActive prop @@ -3687,17 +3773,15 @@ export function TaskDetailContent({ try { await updateTask(workingTask.id, { prompt: newContent }, projectId); addToast(t("taskDetail.spec.updated", "Spec updated"), "success"); - // Update local detail data - if (fullDetail) { - fullDetail.prompt = newContent; - } + // FNXC:TaskDetailPlan 2026-08-03-02:06: update immutably so the preview reflects an explicit save. + setFullDetail((previous) => previous ? { ...previous, prompt: newContent } : previous); } catch (err) { addToast(getErrorMessage(err), "error"); throw err; } finally { setIsSavingSpec(false); } - }, [workingTask, fullDetail, addToast]); + }, [workingTask, addToast]); const handleRequestSpecRevision = useCallback(async (feedback: string) => { setIsRequestingRevision(true); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx index a67c6c0d1e..92fcd07e8e 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.definition-actions.test.tsx @@ -497,12 +497,14 @@ describe("TaskDetailModal", () => { expect(screen.getByText("Approval needed before implementation")).toBeTruthy(); expect(screen.getByText(/require a human decision before work starts/i)).toBeTruthy(); - fireEvent.click(bannerApprove); + const user = userEvent.setup(); + await user.click(bannerApprove); await waitFor(() => { expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined); }); - fireEvent.click(bannerReject); + mockConfirm.mockResolvedValueOnce(true); + await user.click(bannerReject); await waitFor(() => { expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined); }); @@ -1743,4 +1745,89 @@ describe("TaskDetailModal", () => { }); + describe("Definition prompt freshness", () => { + afterEach(() => vi.useRealTimers()); + + it("shares the slim-task initial load with the first visible Definition refresh", async () => { + const { fetchTaskDetail } = await import("../../api"); + const mockFetchDetail = vi.mocked(fetchTaskDetail); + mockFetchDetail.mockReset(); + mockFetchDetail.mockResolvedValue(makeTask({ id: "FN-slim", column: "triage", status: "planning", prompt: "# Authoritative prompt" })); + const slimTask = { ...makeTask({ id: "FN-slim", column: "triage", status: "planning" }) } as Partial; + delete slimTask.prompt; + + render(); + + await waitFor(() => expect(screen.getByText("Authoritative prompt")).toBeTruthy()); + expect(mockFetchDetail).toHaveBeenCalledTimes(1); + expect(mockFetchDetail).toHaveBeenCalledWith("FN-slim", undefined); + }); + + it("refreshes on show, re-entry, and visible planning polls", async () => { + vi.useFakeTimers(); + const { fetchTaskDetail } = await import("../../api"); + const mockFetchDetail = vi.mocked(fetchTaskDetail); + mockFetchDetail.mockReset(); + mockFetchDetail + .mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# First revision" })) + .mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# Polled revision" })) + .mockResolvedValueOnce(makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "# Re-entered revision" })); + + render(); + + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByText("First revision")).toBeTruthy(); + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(screen.getByText("Polled revision")).toBeTruthy(); + + fireEvent.click(screen.getByText("Activity")); + await act(async () => { await vi.advanceTimersByTimeAsync(15_000); }); + expect(mockFetchDetail).toHaveBeenCalledTimes(2); + + fireEvent.click(screen.getByText("Plan")); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByText("Re-entered revision")).toBeTruthy(); + }); + + it("keeps an inline edit buffer stable while a Plan Review refresh arrives", async () => { + vi.useFakeTimers(); + const { fetchTaskDetail } = await import("../../api"); + const mockFetchDetail = vi.mocked(fetchTaskDetail); + mockFetchDetail.mockReset(); + mockFetchDetail + .mockResolvedValueOnce(makeTask({ id: "FN-edit", column: "todo", prompt: "# Server revision", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] })) + .mockResolvedValueOnce(makeTask({ id: "FN-edit", column: "todo", prompt: "# New server revision", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] })); + + render(); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(screen.getByText("Server revision")).toBeTruthy(); + + fireEvent.click(screen.getByText("Edit")); + const textarea = document.querySelector(".spec-editor-textarea") as HTMLTextAreaElement; + fireEvent.change(textarea, { target: { value: "# Local operator edit" } }); + const sameTextarea = textarea; + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(mockFetchDetail).toHaveBeenCalledTimes(2); + expect(document.querySelector(".spec-editor-textarea")).toBe(sameTextarea); + expect(textarea.value).toBe("# Local operator edit"); + }); + + it("ignores a late planning response after the task changes", async () => { + const { fetchTaskDetail } = await import("../../api"); + const mockFetchDetail = vi.mocked(fetchTaskDetail); + let resolveFirst: (detail: TaskDetail) => void = () => {}; + mockFetchDetail.mockReset(); + mockFetchDetail + .mockImplementationOnce(() => new Promise((resolve) => { resolveFirst = resolve; })) + .mockResolvedValueOnce(makeTask({ id: "FN-current", column: "triage", status: "planning", prompt: "# Current task" })); + const props = { initialTab: "definition" as const, onMoveTask: noopMove, onDeleteTask: noopDelete, onMergeTask: noopMerge, onOpenDetail: noopOpenDetail, addToast: noop }; + const view = render(); + view.rerender(); + await waitFor(() => expect(screen.getByText("Current task")).toBeTruthy()); + await act(async () => { resolveFirst(makeTask({ id: "FN-old", column: "triage", status: "planning", prompt: "# Stale task" })); }); + expect(screen.queryByText("Stale task")).toBeNull(); + expect(screen.getByText("Current task")).toBeTruthy(); + }); + }); + }); diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.popup-hidden-gating.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.popup-hidden-gating.test.tsx index 534f2516f6..e546cd67e0 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.popup-hidden-gating.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.popup-hidden-gating.test.tsx @@ -22,9 +22,9 @@ true): paste re-registers, polling refreshes immediately and re-arms the interva */ setupTaskDetailModalHooks(); -function renderContent(active: boolean) { +function renderContent(active: boolean, task = makeTask({ id: "FN-9001" })) { const props = { - task: makeTask({ id: "FN-9001" }), + task, onMoveTask: noopMove, onDeleteTask: noopDelete, onMergeTask: noopMerge, @@ -85,6 +85,27 @@ describe("TaskDetailContent hidden-popup gating (active=false)", () => { }); }); + it("keeps Definition prompt refresh request-free while hidden and resumes one planning chain on reveal", async () => { + vi.useFakeTimers(); + const mockDetail = vi.mocked(dashboardApi.fetchTaskDetail); + mockDetail.mockReset(); + mockDetail.mockResolvedValue(makeTask({ id: "FN-9001", column: "triage", status: "planning", prompt: "# Fresh plan" })); + + const { rerenderWithActive } = renderContent(false, makeTask({ id: "FN-9001", column: "triage", status: "planning" })); + await act(async () => { await vi.advanceTimersByTimeAsync(16_000); }); + expect(mockDetail).not.toHaveBeenCalled(); + + rerenderWithActive(true); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + expect(mockDetail).toHaveBeenCalledTimes(1); + await act(async () => { await vi.advanceTimersByTimeAsync(5_000); }); + expect(mockDetail).toHaveBeenCalledTimes(2); + + rerenderWithActive(false); + await act(async () => { await vi.advanceTimersByTimeAsync(16_000); }); + expect(mockDetail).toHaveBeenCalledTimes(2); + }); + it("suspends the 5s verification polling while hidden and resumes it on reveal", async () => { vi.useFakeTimers(); const mockVerification = vi.mocked(dashboardApi.fetchTaskVerificationRequest);