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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-02 19:38:35 -07:00
parent 4a67883bb0
commit da17a57bde
5 changed files with 215 additions and 15 deletions

View File

@@ -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.

View File

@@ -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.

View File

@@ -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<Task, "status" | "workflowStepResults">): 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<TaskVerificationRequest | null>(null);
const detailRequestGenerationRef = useRef(0);
const detailRequestRef = useRef<{ key: string; promise: Promise<TaskDetail> } | 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);

View File

@@ -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<TaskDetail>;
delete slimTask.prompt;
render(<TaskDetailContent task={slimTask as TaskDetail} initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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(<TaskDetailContent task={makeTask({ id: "FN-fresh", column: "triage", status: "planning", prompt: "" })} initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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(<TaskDetailContent task={makeTask({ id: "FN-edit", column: "todo", prompt: "# Initial", workflowStepResults: [{ workflowStepId: "plan-review", status: "pending", startedAt: "2026-08-03T02:00:00Z" }] })} initialTab="definition" onMoveTask={noopMove} onDeleteTask={noopDelete} onMergeTask={noopMerge} onOpenDetail={noopOpenDetail} addToast={noop} />);
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<TaskDetail>((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(<TaskDetailContent {...props} task={makeTask({ id: "FN-old", column: "triage", status: "planning", prompt: "# Old task" })} />);
view.rerender(<TaskDetailContent {...props} task={makeTask({ id: "FN-current", column: "triage", status: "planning", prompt: "" })} />);
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();
});
});
});

View File

@@ -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);