fix(planning): complete task creation handoff
Treat active creation claims as transient coordination, keep the created-task handoff visible, and provide direct task and session navigation across desktop and mobile.
This commit is contained in:
7
.changeset/fix-planning-task-creation-handoff.md
Normal file
7
.changeset/fix-planning-task-creation-handoff.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Finish plan task creation automatically and show links to the task or planning sessions.
|
||||||
|
category: fix
|
||||||
|
dev: Retries transient planning creation claims and keeps a durable success handoff visible.
|
||||||
@@ -787,7 +787,7 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
|||||||
gap: var(--space-sm);
|
gap: var(--space-sm);
|
||||||
padding: var(--space-md);
|
padding: var(--space-md);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
border: 1px solid var(--border);
|
border: var(--btn-border-width) solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1863,6 +1863,72 @@ plan actions, and a token-sized bottom inset keep all three controls inline with
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-07-20-23:20:
|
||||||
|
Successful plan creation remains a compact handoff instead of closing Planning or presenting
|
||||||
|
another review screen. Reuse the shared button primitives and semantic success token so both
|
||||||
|
mobile and desktop offer the same View task / Return to sessions choices.
|
||||||
|
*/
|
||||||
|
.planning-task-created {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-lg);
|
||||||
|
padding: var(--space-xl);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-icon {
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
width: calc(var(--space-2xl) + var(--space-lg));
|
||||||
|
aspect-ratio: 1;
|
||||||
|
color: var(--color-success);
|
||||||
|
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
||||||
|
border: var(--btn-border-width) solid color-mix(in srgb, var(--color-success) 35%, var(--border));
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
max-width: calc(var(--space-2xl) * 12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-copy h4,
|
||||||
|
.planning-task-created-copy p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-copy p {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-id {
|
||||||
|
padding: var(--space-xs) var(--space-sm);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-pill);
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-actions .btn {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
/* AI Thinking Output Display */
|
/* AI Thinking Output Display */
|
||||||
.planning-thinking-container {
|
.planning-thinking-container {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2051,6 +2117,15 @@ plan actions, and a token-sized bottom inset keep all three controls inline with
|
|||||||
|
|
||||||
/* Responsive */
|
/* Responsive */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
.planning-task-created-actions {
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.planning-task-created-actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.planning-modal--embedded .modal-header--embedded {
|
.planning-modal--embedded .modal-header--embedded {
|
||||||
align-content: center;
|
align-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
@@ -79,12 +79,44 @@ const PLANNING_SIDEBAR_MAX_WIDTH = 560;
|
|||||||
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
const PLANNING_SIDEBAR_STORAGE_KEY = "fusion:planning-sidebar-width";
|
||||||
|
|
||||||
const MAX_PLANNING_AUTO_RETRIES = 3;
|
const MAX_PLANNING_AUTO_RETRIES = 3;
|
||||||
|
const MAX_PLANNING_CREATE_CLAIM_RETRIES = 20;
|
||||||
|
|
||||||
|
function isPlanningCreateClaimConflict(error: unknown): boolean {
|
||||||
|
return typeof error === "object"
|
||||||
|
&& error !== null
|
||||||
|
&& "status" in error
|
||||||
|
&& (error as { status?: unknown }).status === 409
|
||||||
|
&& error instanceof Error
|
||||||
|
&& error.message.includes("Planning task creation is already in progress");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTaskAfterActiveClaim(createTask: () => Promise<Task>): Promise<Task> {
|
||||||
|
for (let retryCount = 0; ; retryCount += 1) {
|
||||||
|
try {
|
||||||
|
return await createTask();
|
||||||
|
} catch (error) {
|
||||||
|
if (!isPlanningCreateClaimConflict(error) || retryCount >= MAX_PLANNING_CREATE_CLAIM_RETRIES) throw error;
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-07-20-23:20:
|
||||||
|
A 409 create-claim response is cross-process coordination, not a failed user action. The
|
||||||
|
endpoint is idempotent by planning session, so keep the single Proceed action in its loading
|
||||||
|
state and retry until the active creator returns the one canonical task (or its lease expires).
|
||||||
|
The first retry is immediate for the common just-finished race; later retries are bounded.
|
||||||
|
*/
|
||||||
|
if (retryCount > 0) {
|
||||||
|
await new Promise<void>((resolve) => window.setTimeout(resolve, Math.min(750 * retryCount, 2_000)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface PlanningModeModalProps {
|
interface PlanningModeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onTaskCreated: (task: Task) => void;
|
onTaskCreated: (task: Task) => void;
|
||||||
onTasksCreated: (tasks: Task[]) => void;
|
onTasksCreated: (tasks: Task[]) => void;
|
||||||
|
/** FNXC:PlanningMode 2026-07-20-23:20: Open the task produced by a completed planning session from the durable success handoff. */
|
||||||
|
onViewTask?: (task: Task) => void;
|
||||||
tasks: Task[];
|
tasks: Task[];
|
||||||
initialPlan?: string;
|
initialPlan?: string;
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -109,7 +141,7 @@ type ViewState =
|
|||||||
| { type: "plan_review"; session: PlanningSession; summary: PlanningSummary }
|
| { type: "plan_review"; session: PlanningSession; summary: PlanningSummary }
|
||||||
| { type: "creating_task"; session: PlanningSession; summary: PlanningSummary }
|
| { type: "creating_task"; session: PlanningSession; summary: PlanningSummary }
|
||||||
| { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string }
|
| { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string }
|
||||||
| { type: "task_created"; taskId: string }
|
| { type: "task_created"; taskId: string; task?: Task }
|
||||||
| { type: "error"; session: PlanningSession; errorMessage: string }
|
| { type: "error"; session: PlanningSession; errorMessage: string }
|
||||||
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
||||||
| { type: "loading" };
|
| { type: "loading" };
|
||||||
@@ -334,7 +366,7 @@ function parseModelSelection(value: string): { provider?: string; modelId?: stri
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) {
|
export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreated, onViewTask, tasks, initialPlan: initialPlanProp, projectId, workflowId, resumeSessionId, initialSessions, presentation = "modal" }: PlanningModeModalProps) {
|
||||||
const { t } = useTranslation("app");
|
const { t } = useTranslation("app");
|
||||||
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
|
// FNXC:EmbeddedPresentation 2026-06-22-12:00: shared hook supplies isEmbedded (DOM branching) plus the modal-only gates.
|
||||||
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
|
// Note: the Escape handler intentionally does NOT gate on embedded here — embedded planning preserves its historical
|
||||||
@@ -2138,14 +2170,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
*/
|
*/
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (view.type !== "task_created" || restoredTaskHandoffRef.current === view.taskId) return;
|
if (view.type !== "task_created" || restoredTaskHandoffRef.current === view.taskId) return;
|
||||||
const task = tasks.find((candidate) => candidate.id === view.taskId);
|
const task = view.task ?? tasks.find((candidate) => candidate.id === view.taskId);
|
||||||
if (!task) return;
|
if (!task) return;
|
||||||
restoredTaskHandoffRef.current = task.id;
|
restoredTaskHandoffRef.current = task.id;
|
||||||
onTaskCreated(task);
|
onTaskCreated(task);
|
||||||
clearPlanningActiveSession(projectId);
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
}, [onTaskCreated, projectId, tasks, view]);
|
||||||
handleClose();
|
|
||||||
}, [handleClose, onTaskCreated, projectId, tasks, view]);
|
|
||||||
|
|
||||||
// Handle escape key to close
|
// Handle escape key to close
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -2385,13 +2415,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const validated = await validatePlanningSession(sessionId, projectId);
|
const validated = await validatePlanningSession(sessionId, projectId);
|
||||||
validationCompleted = true;
|
validationCompleted = true;
|
||||||
const validatedSummary = normalizePlanningSummary(validated.summary);
|
const validatedSummary = normalizePlanningSummary(validated.summary);
|
||||||
const task = await createTaskFromPlanning(sessionId, validatedSummary, projectId, {
|
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(sessionId, validatedSummary, projectId, {
|
||||||
...(workflowId !== undefined ? { workflowId } : {}),
|
...(workflowId !== undefined ? { workflowId } : {}),
|
||||||
});
|
}));
|
||||||
onTaskCreated(task);
|
|
||||||
clearPlanningActiveSession(projectId);
|
clearPlanningActiveSession(projectId);
|
||||||
setSelectedSessionId(null);
|
setView({ type: "task_created", taskId: task.id, task });
|
||||||
handleClose();
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task");
|
const errorMessage = getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task");
|
||||||
if (validationCompleted) {
|
if (validationCompleted) {
|
||||||
@@ -2403,21 +2431,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
} finally {
|
} finally {
|
||||||
validateCreateInFlightRef.current = false;
|
validateCreateInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, [handleClose, onTaskCreated, projectId, t, workflowId, workspaceQuestion]);
|
}, [projectId, t, workflowId, workspaceQuestion]);
|
||||||
|
|
||||||
const handleRetryCreateTask = useCallback(async () => {
|
const handleRetryCreateTask = useCallback(async () => {
|
||||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||||
validateCreateInFlightRef.current = true;
|
validateCreateInFlightRef.current = true;
|
||||||
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
||||||
try {
|
try {
|
||||||
const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) });
|
const task = await createTaskAfterActiveClaim(() => createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) }));
|
||||||
onTaskCreated(task); clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose();
|
clearPlanningActiveSession(projectId);
|
||||||
|
setView({ type: "task_created", taskId: task.id, task });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||||
} finally {
|
} finally {
|
||||||
validateCreateInFlightRef.current = false;
|
validateCreateInFlightRef.current = false;
|
||||||
}
|
}
|
||||||
}, [handleClose, onTaskCreated, projectId, t, view, workflowId]);
|
}, [projectId, t, view, workflowId]);
|
||||||
|
|
||||||
const handleCreateTask = useCallback(async () => {
|
const handleCreateTask = useCallback(async () => {
|
||||||
if (view.type !== "summary") return;
|
if (view.type !== "summary") return;
|
||||||
@@ -3187,7 +3216,31 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
{view.type === "creating_task" && <div className="planning-loading"><Loader2 size={24} className="spin" /> {t("planning.creatingTask", "Creating task…")}</div>}
|
{view.type === "creating_task" && <div className="planning-loading"><Loader2 size={24} className="spin" /> {t("planning.creatingTask", "Creating task…")}</div>}
|
||||||
{view.type === "task_created" && (
|
{view.type === "task_created" && (
|
||||||
<div className="planning-loading" data-testid="planning-task-created"><CheckCircle size={24} /> {t("planning.taskCreated", "Task created")}</div>
|
<div className="planning-task-created" data-testid="planning-task-created" role="status" aria-live="polite">
|
||||||
|
<div className="planning-task-created-icon"><CheckCircle size={28} /></div>
|
||||||
|
<div className="planning-task-created-copy">
|
||||||
|
<h4>{t("planning.taskCreated", "Task created")}</h4>
|
||||||
|
<p>{t("planning.taskCreatedHint", "Your approved plan is ready to work on.")}</p>
|
||||||
|
<span className="planning-task-created-id">{view.taskId}</span>
|
||||||
|
</div>
|
||||||
|
<div className="planning-task-created-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!onViewTask || !(view.task ?? tasks.find((candidate) => candidate.id === view.taskId))}
|
||||||
|
onClick={() => {
|
||||||
|
const task = view.task ?? tasks.find((candidate) => candidate.id === view.taskId);
|
||||||
|
if (task) onViewTask?.(task);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("planning.viewTask", "View task")}
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn" onClick={handleBackToList}>
|
||||||
|
{t("planning.returnToSessions", "Return to sessions")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{view.type === "create_retry" && (
|
{view.type === "create_retry" && (
|
||||||
<div className="planning-summary" data-testid="planning-create-retry">
|
<div className="planning-summary" data-testid="planning-create-retry">
|
||||||
|
|||||||
@@ -152,7 +152,7 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
await waitFor(() => expect(historyButton).toHaveFocus());
|
await waitFor(() => expect(historyButton).toHaveFocus());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("creates the task directly when the user proceeds with the plan", async () => {
|
it("creates the task directly and offers task and session-list handoffs", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({
|
mockFetchAiSession.mockResolvedValue({
|
||||||
...base,
|
...base,
|
||||||
status: "awaiting_input",
|
status: "awaiting_input",
|
||||||
@@ -160,7 +160,10 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
result: JSON.stringify(summaryWithRefinements),
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
inputPayload: "{}",
|
inputPayload: "{}",
|
||||||
});
|
});
|
||||||
renderSession({});
|
const onClose = vi.fn();
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
|
const onViewTask = vi.fn();
|
||||||
|
render(<PlanningModeModal isOpen onClose={onClose} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||||
|
|
||||||
@@ -172,6 +175,86 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
{},
|
{},
|
||||||
));
|
));
|
||||||
expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull();
|
expect(screen.queryByRole("heading", { name: "Review your plan" })).toBeNull();
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442");
|
||||||
|
expect(onTaskCreated).toHaveBeenCalledWith({ id: "FN-8442" });
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "View task" }));
|
||||||
|
expect(onViewTask).toHaveBeenCalledWith({ id: "FN-8442" });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Return to sessions" }));
|
||||||
|
expect(await screen.findByRole("complementary", { name: "Planning sessions" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("automatically resolves an in-progress create claim without showing retry UI", async () => {
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
});
|
||||||
|
mockCreateTaskFromPlanning
|
||||||
|
.mockRejectedValueOnce(Object.assign(new Error("Planning task creation is already in progress"), { status: 409 }))
|
||||||
|
.mockResolvedValueOnce({ id: "FN-8442" });
|
||||||
|
|
||||||
|
renderSession({});
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-8442");
|
||||||
|
expect(mockCreateTaskFromPlanning).toHaveBeenCalledTimes(2);
|
||||||
|
expect(screen.queryByTestId("planning-create-retry")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps both created-task handoffs reachable on mobile", async () => {
|
||||||
|
mockViewportMode.mockReturnValue("mobile");
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify({ id: "q-1", type: "text", question: "Anything else?" }),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
});
|
||||||
|
const onViewTask = vi.fn();
|
||||||
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: "Proceed with plan" }));
|
||||||
|
|
||||||
|
expect(await screen.findByRole("button", { name: "View task" })).toBeEnabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Return to sessions" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("restores a linked task into the created-task handoff", async () => {
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "complete",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(mockSummary),
|
||||||
|
inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-001" }),
|
||||||
|
});
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
|
const onViewTask = vi.fn();
|
||||||
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={onViewTask} tasks={mockTasks} projectId="project-1" resumeSessionId="session-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-001");
|
||||||
|
await waitFor(() => expect(onTaskCreated).toHaveBeenCalledWith(mockTasks[0]));
|
||||||
|
expect(screen.getByRole("button", { name: "View task" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for a restored linked task before enabling its task handoff", async () => {
|
||||||
|
mockFetchAiSession.mockResolvedValue({
|
||||||
|
...base,
|
||||||
|
status: "complete",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(mockSummary),
|
||||||
|
inputPayload: JSON.stringify({ validated: true, createdTaskId: "FN-LATER" }),
|
||||||
|
});
|
||||||
|
const onTaskCreated = vi.fn();
|
||||||
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={onTaskCreated} onTasksCreated={vi.fn()} onViewTask={vi.fn()} tasks={[]} projectId="project-1" resumeSessionId="session-1" />);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("planning-task-created")).toHaveTextContent("FN-LATER");
|
||||||
|
expect(screen.getByRole("button", { name: "View task" })).toBeDisabled();
|
||||||
|
expect(onTaskCreated).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses full-view Questions and Plan preview tabs on mobile", async () => {
|
it("uses full-view Questions and Plan preview tabs on mobile", async () => {
|
||||||
|
|||||||
@@ -721,6 +721,7 @@ export function MainContent({
|
|||||||
onClose={closePlanningView}
|
onClose={closePlanningView}
|
||||||
onTaskCreated={handlePlanningTaskCreated}
|
onTaskCreated={handlePlanningTaskCreated}
|
||||||
onTasksCreated={handlePlanningTasksCreated}
|
onTasksCreated={handlePlanningTasksCreated}
|
||||||
|
onViewTask={openBoardTaskDetail}
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
initialSessions={bgPlanningSessions}
|
initialSessions={bgPlanningSessions}
|
||||||
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
initialPlan={modalManager.planningInitialPlan ?? undefined}
|
||||||
|
|||||||
Reference in New Issue
Block a user