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:
7
.changeset/fn-8731-definition-prompt-refresh.md
Normal file
7
.changeset/fn-8731-definition-prompt-refresh.md
Normal 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.
|
||||||
@@ -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.
|
- 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.
|
- 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 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 `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.
|
- 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.
|
- 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.
|
||||||
|
|||||||
@@ -102,6 +102,17 @@ const ACTIVITY_VIEW_MENU_MIN_WIDTH = 160;
|
|||||||
const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120;
|
const ACTIVITY_VIEW_MENU_MIN_HEIGHT = 120;
|
||||||
const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
|
const ACTIVITY_VIEW_MENU_MAX_HEIGHT = 320;
|
||||||
const ACTIVITY_VIEW_MENU_OPEN_VIEWPORT_GUARD_MS = 350;
|
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: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.
|
// 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),
|
!("prompt" in task),
|
||||||
);
|
);
|
||||||
const [verificationRequest, setVerificationRequest] = useState<TaskVerificationRequest | null>(null);
|
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:
|
FNXC:TaskPopupViewGating 2026-07-23-10:20:
|
||||||
@@ -848,6 +880,8 @@ export function TaskDetailContent({
|
|||||||
}, [task.id, projectId, active]);
|
}, [task.id, projectId, active]);
|
||||||
|
|
||||||
useEffect(() => {
|
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 the prop already has a prompt field, it's a full TaskDetail
|
||||||
if ("prompt" in task) {
|
if ("prompt" in task) {
|
||||||
setFullDetail(task as TaskDetail);
|
setFullDetail(task as TaskDetail);
|
||||||
@@ -856,24 +890,25 @@ export function TaskDetailContent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const requestGeneration = ++detailRequestGenerationRef.current;
|
||||||
setDetailLoading(true);
|
setDetailLoading(true);
|
||||||
setFullDetail(null);
|
setFullDetail(null);
|
||||||
|
|
||||||
fetchTaskDetail(task.id, projectId)
|
requestTaskDetail(task.id, projectId)
|
||||||
.then((detail) => {
|
.then((detail) => {
|
||||||
if (!cancelled) {
|
if (!cancelled && detailRequestGenerationRef.current === requestGeneration) {
|
||||||
setFullDetail(detail);
|
setFullDetail(detail);
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled && detailRequestGenerationRef.current === requestGeneration) {
|
||||||
setDetailLoading(false);
|
setDetailLoading(false);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [task.id, projectId]);
|
}, [task.id, projectId, active, requestTaskDetail]);
|
||||||
|
|
||||||
// Derive a working task that always has all available fields.
|
// Derive a working task that always has all available fields.
|
||||||
// Falls back to the optimistic Task while loading, uses fullDetail once loaded.
|
// 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 [specEditContent, setSpecEditContent] = useState(workingTask.prompt || "");
|
||||||
const [specFeedback, setSpecFeedback] = useState("");
|
const [specFeedback, setSpecFeedback] = useState("");
|
||||||
const [showRefineModal, setShowRefineModal] = useState(false);
|
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);
|
const [prCreateOpen, setPrCreateOpen] = useState(false);
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
@@ -1956,12 +2037,17 @@ export function TaskDetailContent({
|
|||||||
return () => document.removeEventListener("keydown", handleKeyDown);
|
return () => document.removeEventListener("keydown", handleKeyDown);
|
||||||
}, [showMoveMenu, showActionsMenu, showActivityViewMenu, showOversightMenu, showInlinePriorityPicker]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
setIsEditingSpec(false);
|
setIsEditingSpec(false);
|
||||||
setSpecEditContent(workingTask.prompt || "");
|
setSpecEditContent(workingTask.prompt || "");
|
||||||
setSpecFeedback("");
|
setSpecFeedback("");
|
||||||
}, [task.id, workingTask.prompt]);
|
}, [task.id]);
|
||||||
|
|
||||||
// Note: TaskForm handles auto-focus internally via isActive prop
|
// Note: TaskForm handles auto-focus internally via isActive prop
|
||||||
|
|
||||||
@@ -3687,17 +3773,15 @@ export function TaskDetailContent({
|
|||||||
try {
|
try {
|
||||||
await updateTask(workingTask.id, { prompt: newContent }, projectId);
|
await updateTask(workingTask.id, { prompt: newContent }, projectId);
|
||||||
addToast(t("taskDetail.spec.updated", "Spec updated"), "success");
|
addToast(t("taskDetail.spec.updated", "Spec updated"), "success");
|
||||||
// Update local detail data
|
// FNXC:TaskDetailPlan 2026-08-03-02:06: update immutably so the preview reflects an explicit save.
|
||||||
if (fullDetail) {
|
setFullDetail((previous) => previous ? { ...previous, prompt: newContent } : previous);
|
||||||
fullDetail.prompt = newContent;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
addToast(getErrorMessage(err), "error");
|
addToast(getErrorMessage(err), "error");
|
||||||
throw err;
|
throw err;
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingSpec(false);
|
setIsSavingSpec(false);
|
||||||
}
|
}
|
||||||
}, [workingTask, fullDetail, addToast]);
|
}, [workingTask, addToast]);
|
||||||
|
|
||||||
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
|
const handleRequestSpecRevision = useCallback(async (feedback: string) => {
|
||||||
setIsRequestingRevision(true);
|
setIsRequestingRevision(true);
|
||||||
|
|||||||
@@ -497,12 +497,14 @@ describe("TaskDetailModal", () => {
|
|||||||
expect(screen.getByText("Approval needed before implementation")).toBeTruthy();
|
expect(screen.getByText("Approval needed before implementation")).toBeTruthy();
|
||||||
expect(screen.getByText(/require a human decision before work starts/i)).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(() => {
|
await waitFor(() => {
|
||||||
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
expect(mockApprovePlan).toHaveBeenCalledWith("FN-001", undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
fireEvent.click(bannerReject);
|
mockConfirm.mockResolvedValueOnce(true);
|
||||||
|
await user.click(bannerReject);
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockRejectPlan).toHaveBeenCalledWith("FN-001", undefined);
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ true): paste re-registers, polling refreshes immediately and re-arms the interva
|
|||||||
*/
|
*/
|
||||||
setupTaskDetailModalHooks();
|
setupTaskDetailModalHooks();
|
||||||
|
|
||||||
function renderContent(active: boolean) {
|
function renderContent(active: boolean, task = makeTask({ id: "FN-9001" })) {
|
||||||
const props = {
|
const props = {
|
||||||
task: makeTask({ id: "FN-9001" }),
|
task,
|
||||||
onMoveTask: noopMove,
|
onMoveTask: noopMove,
|
||||||
onDeleteTask: noopDelete,
|
onDeleteTask: noopDelete,
|
||||||
onMergeTask: noopMerge,
|
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 () => {
|
it("suspends the 5s verification polling while hidden and resumes it on reveal", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const mockVerification = vi.mocked(dashboardApi.fetchTaskVerificationRequest);
|
const mockVerification = vi.mocked(dashboardApi.fetchTaskVerificationRequest);
|
||||||
|
|||||||
Reference in New Issue
Block a user