FN-8442: redesign planning mode as sequential Q&A
Guide planning through sequential question-and-answer turns before task creation. - Add plan review, refinement, validation, creation retry, and idempotent task handoff states - Atomically claim planning-session task creation and reconcile persisted task links - Replace retired multi-pane planning UI and consolidate focused flow coverage - Document the sequential planning workflow and mobile behavior Files changed: .changeset/fn-8442-planning-sequential-qa.md | 7 + MOBILE.md | 2 +- docs/dashboard-guide.md | 9 +- packages/core/src/async-ai-session-store.ts | 82 +- packages/core/src/index.ts | 4 + packages/core/src/types.ts | 5 + packages/dashboard/app/api/legacy.ts | 4 +- .../dashboard/app/components/PlanningModeModal.css | 178 +- .../dashboard/app/components/PlanningModeModal.tsx | 317 +- .../__tests__/PlanningModeModal.initial.test.tsx | 993 +---- .../PlanningModeModal.planning-flow.test.tsx | 4377 +------------------- .../PlanningModeModal.ui-interactions.test.tsx | 1276 +----- .../__tests__/planning-e2e-plan-creation.test.ts | 25 +- .../dashboard/src/__tests__/planning-infinite-interview.test.ts | 44 +- .../src/__tests__/routes-planning.test.ts | 119 +- packages/dashboard/src/ai-session-store.ts | 33 + packages/dashboard/src/planning.ts | 140 +- .../src/routes/register-planning-subtask-routes.ts | 132 +- 18 files changed, 649 insertions(+), 7098 deletions(-) Fusion-Task-Id: FN-8442 Fusion-Task-Lineage: da2f335f-b1c9-411a-8575-31db20a2b8d5 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-8442-planning-sequential-qa.md
Normal file
7
.changeset/fn-8442-planning-sequential-qa.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
summary: Simplify Planning Mode to a sequential Q&A and plan-review flow with focus-steered refine.
|
||||
category: feature
|
||||
dev: Replaces three-pane interview with question → plan_review → refine/validate; refine accepts optional focus text; Validate creates the task; create-task is idempotent per planning session.
|
||||
@@ -101,7 +101,7 @@ gesture delivery enabled across `cap sync` regenerations.
|
||||
|
||||
### Planning Mode
|
||||
|
||||
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Desktop keeps its three-pane interview (answered history, current question, and running plan) when there is room. Mobile, phone-class short landscape, and tablet use a progressive interview: **Question** is the default full-width surface, while labeled **Running plan** and **Answered questions** controls switch to those surfaces without stacking three panes. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer; **Running plan** appears only after you intentionally open a session and select its tab. **Sessions** and the mobile back control always return to that list. The same escape is available from interview, summary, breakdown, and a new-session composer when saved sessions exist; **Validate plan** remains on the Running plan surface.
|
||||
Planning Mode opens directly into the composer pane on mobile when no planning sessions exist, avoiding an empty-sidebar dead end. Every viewport uses the same sequential surface: idea, one question, then plan review. Plan review provides an optional focus field followed by **Refine** and **Validate**; refine asks the next question and validate creates the task. There is no three-pane interview or Question/Running plan/Answered questions tab switcher. On mobile, opening Planning with saved sessions lands on the full-pane, scrollable saved-session list with **New session** as its footer. **Sessions** and the mobile back control always return to that list, including from plan review and create retry.
|
||||
|
||||
### Chat and Quick Chat mobile scroll/readability behavior
|
||||
|
||||
|
||||
@@ -523,18 +523,17 @@ Planning is a desktop/tablet left-sidebar main-content destination after **Comma
|
||||
When a Planning session needs your input or needs attention, open the docked Planning view from the **Planning** navigation item. Its yellow needs-input dot is visible on the desktop left sidebar and mobile More controls. Non-planning in-progress, needs-input, and error sessions appear in the session notification banner, where available Resume actions reconnect to their matching surface.
|
||||
|
||||
<!-- FNXC:PlanningRetry 2026-07-15-00:00: FN-8332 confines automatic Planning Mode retry to failures observed by an active in-session SSE/poll turn. Browser reload or session resume must restore the persisted progress/error verbatim and leave retry as an explicit user choice. -->
|
||||
When an active Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. Leaving Planning—including while a next question is generating—and returning restores the last active interview for that project: a generation reconnects with its saved thinking, while a turn that completed while away shows its new question and running plan. Reopening or reloading a saved Planning session restores its saved question, summary, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again.
|
||||
When an active Planning AI generation appears stuck, Planning Mode automatically retries the same session up to three times and shows **Retrying… (attempt N of 3)** before falling back to the permanent **Retry**/**Dismiss** error panel. Any successful question or summary progress resets the automatic retry budget. Leaving Planning—including while a plan update or refined question is generating—and returning restores the last active interview for that project. Reopening or reloading a saved Planning session restores its saved question, plan review, thinking, or error without starting another generation; choose **Retry** explicitly from a restored error panel if you want to run it again.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-19-15:55: FN-8400 replaces the duplicate prompt-recovery controls with a focused three-pane interview; restarting remains a deliberate New session action. -->
|
||||
Use **New session** to restart planning with a different idea.
|
||||
|
||||
Planning Mode uses the selected workflow's `planning` seam—the same triage template used for newly added tasks—as its quality bar, then layers the user-controlled interview adapter on top. An explicitly configured `planning-system` prompt override replaces that full system prompt.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-18-16:00: Planning Mode is an infinite, user-controlled interview. Each answer updates the running plan and produces another context-aware high-impact question; only Validate plan finalizes it. -->
|
||||
<!-- FNXC:PlanningMode 2026-07-20-12:42: FN-8438 requires the first AI turn to draft the running plan from the operator idea, then refine that work product after every answer instead of showing an interview transcript. -->
|
||||
Planning Mode keeps the running plan visible beside answered-question history and the current question on desktop; the AI drafts its title, description, and concrete deliverables from your idea, then refines them after each answer. It is an evolving work product, not a transcript or list of interview questions. You can rename a session and keep asking high-impact, context-aware questions until you choose **Validate plan**. On tablet, mobile, and phone-class short landscape, the interview switches between labeled **Question**, **Running plan**, and **Answered questions** surfaces so the current question stays usable instead of competing with three columns. On mobile, Planning opens to the full-pane, scrollable saved-session list when sessions exist; **Running plan** appears only after you intentionally open a session and choose its tab. **Sessions** (and mobile Back) return to that list with **New session** pinned as its footer. This escape remains available from interview, summary, breakdown, and a new-session composer whenever saved sessions exist, while **Validate plan** remains available on the Running plan surface. The running title, description, and deliverables are available throughout the interview—including while the next question is generating or a recoverable error is shown. The AI never ends an interview on its own. Selection questions provide alternatives with pros and cons plus an **Other** free-text choice, whose wording follows your input language and whose answer steers the next question. You may edit an earlier answer by question ID without losing later answers; Planning re-derives the running plan and appends a fresh next question.
|
||||
<!-- FNXC:PlanningMode 2026-07-20-15:45: FN-8442 replaces the simultaneous three-pane interview with a sequential question and plan-review loop. -->
|
||||
Planning Mode is a single-surface sequence: enter an idea, answer one AI question, then review the evolving plan. Plan review shows the title, description, and deliverables plus an optional **Focus the next question** field. Choose **Refine** to ask another high-impact question; its optional focus can name a topic such as security or real-time behavior, or specify the next question directly. Answering that question returns to plan review rather than automatically starting another question.
|
||||
|
||||
Choose **Validate plan** when the running plan is ready for task creation. Validation is durable and is required before **Create task**, **Create tasks**, or **Start breakdown**; those actions reject unvalidated sessions.
|
||||
Choose **Validate** when the plan is ready. Validation is durable and immediately creates the task using the selected workflow and branch settings. If creation is interrupted after validation, Planning restores a create-only retry state; it never validates again or creates a second task. **Sessions** (and mobile Back) remains the escape hatch for browsing, switching, and reviewing session history, with **New session** pinned in the saved-session list. The AI never ends an interview on its own; selection questions still provide alternatives with pros and cons plus an **Other** free-text choice.
|
||||
|
||||
<!-- FNXC:PlanningMode 2026-07-20-12:00: FN-8441 separates the lean Planning Mode artifact from triage's executor specification. -->
|
||||
On creation, the validated running plan becomes **plan.md**: its title, description, size, suggested dependencies, and key deliverables are stored as the task description and task document `plan` (priority remains a task field, not a plan.md section). The original request that started the session is stored separately as `original-description`. The task planning agent later expands plan.md into the executor-ready **PROMPT.md**; PROMPT.md's **Original Description** preserves that original request verbatim.
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
* Transition context: these helpers live in @fusion/core (where the schema is
|
||||
* defined) and are exported so the dashboard's AiSessionStore can import them.
|
||||
*/
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, lte } from "drizzle-orm";
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, lte, sql } from "drizzle-orm";
|
||||
import * as schema from "./postgres/schema/index.js";
|
||||
import type { AsyncDataLayer, DbTransaction } from "./postgres/data-layer.js";
|
||||
|
||||
@@ -291,6 +291,86 @@ export async function updateAiSessionStatus(
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-20:15:
|
||||
FN-8442 requires a database compare-and-set before Planning Mode creates a task. The
|
||||
never-rotated proposalClaimId prevents duplicate task rows, while this conditional
|
||||
ai_sessions transition assigns exactly one live creator across dashboard processes.
|
||||
*/
|
||||
export async function claimPlanningSessionTaskCreation(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
claimOwnerToken: string,
|
||||
claimStartedAt: string,
|
||||
): Promise<AiSessionRow | null> {
|
||||
const existing = await getAiSession(handle, sessionId);
|
||||
if (!existing || existing.type !== "planning") return null;
|
||||
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
|
||||
if (input.validated !== true) return null;
|
||||
const inputPayload = { ...input, createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined };
|
||||
const rows = await handle.update(schema.project.aiSessions)
|
||||
.set({ inputPayload, updatedAt: claimStartedAt })
|
||||
.where(and(
|
||||
eq(schema.project.aiSessions.id, sessionId),
|
||||
eq(schema.project.aiSessions.type, "planning"),
|
||||
sql`coalesce(${schema.project.aiSessions.inputPayload}->>'createClaimStatus', 'none') = 'none'`,
|
||||
))
|
||||
.returning();
|
||||
return rows[0] ? rowToSession(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Finalize only the owner that won claimPlanningSessionTaskCreation. */
|
||||
export async function finalizePlanningSessionTaskCreation(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
claimOwnerToken: string,
|
||||
createdTaskId: string,
|
||||
): Promise<AiSessionRow | null> {
|
||||
const existing = await getAiSession(handle, sessionId);
|
||||
if (!existing || existing.type !== "planning") return null;
|
||||
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
|
||||
const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined };
|
||||
const rows = await handle.update(schema.project.aiSessions)
|
||||
.set({ inputPayload, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`))
|
||||
.returning();
|
||||
return rows[0] ? rowToSession(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Reconcile a task created before a process could finalize its session linkage. */
|
||||
export async function reconcilePlanningSessionTaskCreation(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
createdTaskId: string,
|
||||
): Promise<AiSessionRow | null> {
|
||||
const existing = await getAiSession(handle, sessionId);
|
||||
if (!existing || existing.type !== "planning") return null;
|
||||
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
|
||||
const inputPayload = { ...input, createClaimStatus: "created", createdTaskId, claimOwnerToken: undefined, claimStartedAt: undefined };
|
||||
const rows = await handle.update(schema.project.aiSessions)
|
||||
.set({ inputPayload, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(schema.project.aiSessions.id, sessionId), eq(schema.project.aiSessions.type, "planning")))
|
||||
.returning();
|
||||
return rows[0] ? rowToSession(rows[0]) : null;
|
||||
}
|
||||
|
||||
/** Release only an expired creator's transient ownership; the stable task key is unchanged. */
|
||||
export async function releasePlanningSessionTaskCreation(
|
||||
handle: QueryHandle,
|
||||
sessionId: string,
|
||||
claimOwnerToken: string,
|
||||
): Promise<AiSessionRow | null> {
|
||||
const existing = await getAiSession(handle, sessionId);
|
||||
if (!existing || existing.type !== "planning") return null;
|
||||
const input = safeJsonParse(existing.inputPayload, {}) as Record<string, unknown>;
|
||||
const inputPayload = { ...input, createClaimStatus: "none", claimOwnerToken: undefined, claimStartedAt: undefined };
|
||||
const rows = await handle.update(schema.project.aiSessions)
|
||||
.set({ inputPayload, updatedAt: new Date().toISOString() })
|
||||
.where(and(eq(schema.project.aiSessions.id, sessionId), sql`${schema.project.aiSessions.inputPayload}->>'claimOwnerToken' = ${claimOwnerToken}`))
|
||||
.returning();
|
||||
return rows[0] ? rowToSession(rows[0]) : null;
|
||||
}
|
||||
|
||||
export async function updateAiSessionTitle(
|
||||
handle: QueryHandle,
|
||||
id: string,
|
||||
|
||||
@@ -2531,6 +2531,10 @@ export type { Deployment as AsyncDeployment, Incident as AsyncIncident } from ".
|
||||
export {
|
||||
upsertAiSession,
|
||||
getAiSession,
|
||||
claimPlanningSessionTaskCreation,
|
||||
finalizePlanningSessionTaskCreation,
|
||||
reconcilePlanningSessionTaskCreation,
|
||||
releasePlanningSessionTaskCreation,
|
||||
listActiveAiSessions,
|
||||
listAllAiSessions,
|
||||
listRecoverableAiSessions,
|
||||
|
||||
@@ -5684,6 +5684,11 @@ export interface PlanningSession {
|
||||
summary?: PlanningSummary;
|
||||
/** User explicitly validated the continuously maintained running plan. */
|
||||
validated?: boolean;
|
||||
/** FNXC:PlanningMode 2026-07-20-15:45: Durable planning-to-task handoff cache; proposalClaimId is the crash-safe authority. */
|
||||
createdTaskId?: string;
|
||||
createClaimStatus?: "none" | "creating" | "created";
|
||||
claimOwnerToken?: string;
|
||||
claimStartedAt?: string;
|
||||
/**
|
||||
* Optional per-session auto-merge override for tasks planned in this session.
|
||||
* Not separately persisted; durable form is a branch_groups row keyed by session id.
|
||||
|
||||
@@ -2761,7 +2761,7 @@ export function createTaskFromPlanning(
|
||||
workflowId?: string | null;
|
||||
},
|
||||
): Promise<Task> {
|
||||
return api<Task>(withProjectId("/planning/create-task", projectId), {
|
||||
return api<{ task: Task; alreadyCreated: boolean }>(withProjectId("/planning/create-task", projectId), {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
...(summary ? { sessionId, summary } : { sessionId }),
|
||||
@@ -2770,7 +2770,7 @@ export function createTaskFromPlanning(
|
||||
...(options?.branchSelection ? { branchSelection: options.branchSelection } : {}),
|
||||
...(options?.workflowId !== undefined ? { workflowId: options.workflowId } : {}),
|
||||
}),
|
||||
});
|
||||
}).then((response) => response.task);
|
||||
}
|
||||
|
||||
/** Start subtask breakdown from a completed planning session */
|
||||
|
||||
@@ -539,64 +539,6 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningModeMobileTablet 2026-07-20-09:30:
|
||||
The compact interview class is shared by width-mobile, tablet, and short CSS shells.
|
||||
Only one interview surface is visible at once, so a short landscape viewport cannot retain
|
||||
desktop's three competing columns. Non-phone short screens use this same class as the explicit
|
||||
mismatch guard for the compound shell query.
|
||||
*/
|
||||
.planning-modal-body--compact-interview {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningModeCompactSwitcher 2026-07-20-11:00:
|
||||
FN-8445 requires compact interview tabs to remain directly below the Planning header for
|
||||
Question, Running plan, and Answered questions. History renders before the switcher in DOM,
|
||||
so visual flex order pins the shared chrome above its scrollable sibling on mobile and short shells.
|
||||
*/
|
||||
.planning-modal-body--compact-interview .planning-compact-pane-switcher {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.planning-compact-pane-switcher {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.planning-compact-pane-switcher .btn {
|
||||
flex: 1 0 auto;
|
||||
min-height: calc(var(--space-md) * 2.25);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.planning-modal-body--compact-interview .planning-sidebar,
|
||||
.planning-modal-body--compact-interview .planning-detail,
|
||||
.planning-modal-body--compact-interview .planning-running-plan {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.planning-modal-body--compact-interview .planning-running-plan {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.planning-modal-body--compact-question .planning-sidebar,
|
||||
.planning-modal-body--compact-question .planning-running-plan,
|
||||
.planning-modal-body--compact-plan .planning-sidebar,
|
||||
.planning-modal-body--compact-plan .planning-detail,
|
||||
.planning-modal-body--compact-history .planning-detail,
|
||||
.planning-modal-body--compact-history .planning-running-plan {
|
||||
display: none;
|
||||
}
|
||||
.planning-sidebar {
|
||||
width: 100%;
|
||||
border-right: none;
|
||||
@@ -637,9 +579,7 @@ The New session button must look EXACTLY like Missions' primary sidebar create b
|
||||
RunningPlanPane; this selector is a layout backstop for future render paths so session
|
||||
rows stay scrollable above the New session footer instead of sharing height with a plan.
|
||||
*/
|
||||
.planning-modal-body--show-list .planning-detail,
|
||||
.planning-modal-body--show-list .planning-running-plan,
|
||||
.planning-modal-body--show-list .planning-compact-pane-switcher {
|
||||
.planning-modal-body--show-list .planning-detail {
|
||||
display: none;
|
||||
}
|
||||
/* The rendered Back control needs a full touch target and must not be squeezed out of the title row. */
|
||||
@@ -980,63 +920,6 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-19-12:00:
|
||||
The desktop interview is three panes: answered history, a single question editor, and an always-mounted
|
||||
running plan. On narrow screens the established detail navigation stacks panes without removing the plan.
|
||||
*/
|
||||
.planning-running-plan {
|
||||
display: flex;
|
||||
flex: 0 1 24rem;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-width: 0;
|
||||
padding: var(--space-lg);
|
||||
border-left: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.planning-running-plan h4,
|
||||
.planning-running-plan h5 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-19-15:55:
|
||||
Answered-history rows are the sole in-session navigation: make each question a full-width, readable
|
||||
button so an earlier response can be edited without restoring the retired linear Back control.
|
||||
*/
|
||||
.planning-answered-history-edit {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-sm);
|
||||
overflow: hidden;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.planning-answered-history-edit span {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.planning-answered-history-edit.active {
|
||||
border-color: var(--todo);
|
||||
background: var(--card-hover);
|
||||
}
|
||||
|
||||
.planning-running-plan-content {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-running-plan-content p {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
/* Question View */
|
||||
.planning-question {
|
||||
display: flex;
|
||||
@@ -1518,54 +1401,6 @@ FNXC:PlanningMode 2026-06-25-09:20:
|
||||
Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer must absorb the narrower detail pane instead of relying on the mobile full-width stack. Allow the three labeled actions to wrap inside the pane while preserving the desktop left/right grouping when space is available.
|
||||
*/
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
/* FNXC:PlanningModeMobileTablet 2026-07-20-09:30: tablet uses the same one-surface interview switcher rather than compressing three fixed panes beside the project navigation. */
|
||||
.planning-modal-body--compact-interview {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningModeCompactSwitcher 2026-07-20-11:00:
|
||||
FN-8445 keeps every compact interview pane's shared tabs above Answered questions on tablet,
|
||||
where history otherwise grows before the later DOM sibling in the column.
|
||||
*/
|
||||
.planning-modal-body--compact-interview .planning-compact-pane-switcher {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.planning-compact-pane-switcher {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
overflow-x: auto;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
}
|
||||
|
||||
.planning-compact-pane-switcher .btn {
|
||||
flex: 1 0 auto;
|
||||
min-height: calc(var(--space-md) * 2.25);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.planning-modal-body--compact-interview .planning-sidebar,
|
||||
.planning-modal-body--compact-interview .planning-detail,
|
||||
.planning-modal-body--compact-interview .planning-running-plan {
|
||||
width: 100%;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.planning-modal-body--compact-question .planning-sidebar,
|
||||
.planning-modal-body--compact-question .planning-running-plan,
|
||||
.planning-modal-body--compact-plan .planning-sidebar,
|
||||
.planning-modal-body--compact-plan .planning-detail,
|
||||
.planning-modal-body--compact-history .planning-detail,
|
||||
.planning-modal-body--compact-history .planning-running-plan {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* FNXC:PlanningModeMobile 2026-07-20-10:30: tablet uses the same compact list destination as phone and short shells, so summary/compose detail cannot remain beside saved session rows. */
|
||||
.planning-modal-body--show-list {
|
||||
flex: 1;
|
||||
@@ -1590,9 +1425,7 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.planning-modal-body--show-list .planning-detail,
|
||||
.planning-modal-body--show-list .planning-running-plan,
|
||||
.planning-modal-body--show-list .planning-compact-pane-switcher {
|
||||
.planning-modal-body--show-list .planning-detail {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -2150,10 +1983,3 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
|
||||
.subtask-item-header--between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.planning-answered-history-response {
|
||||
color: var(--text-muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@@ -105,6 +105,10 @@ type ViewState =
|
||||
| { type: "initial" }
|
||||
| { type: "question"; session: PlanningSession }
|
||||
| { type: "summary"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { type: "plan_review"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { type: "creating_task"; session: PlanningSession; summary: PlanningSummary }
|
||||
| { type: "create_retry"; session: PlanningSession; summary: PlanningSummary; errorMessage: string }
|
||||
| { type: "task_created"; taskId: string }
|
||||
| { type: "error"; session: PlanningSession; errorMessage: string }
|
||||
| { type: "breakdown"; sessionId: string; originalSubtasks: SubtaskItem[]; subtasks: SubtaskItem[]; dirty: boolean }
|
||||
| { type: "loading" };
|
||||
@@ -332,7 +336,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
FNXC:Planning 2026-07-15-00:00:
|
||||
FN-8003 keeps the started prompt separate from the editable composer so users can recover the original idea when an interview errors or drifts off track. The composer may be reset or reused, but this value belongs only to the active session.
|
||||
*/
|
||||
const [activePlanPrompt, setActivePlanPrompt] = useState("");
|
||||
const [_activePlanPrompt, setActivePlanPrompt] = useState("");
|
||||
const [view, setView] = useState<ViewState>({ type: "initial" });
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, setResponseHistory] = useState<QuestionResponse[]>([]);
|
||||
@@ -366,7 +370,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
const [editingQuestionId, setEditingQuestionId] = useState<string | null>(null);
|
||||
const editingQuestionIdRef = useRef<string | null>(null);
|
||||
const [isHistoryEditPending, setIsHistoryEditPending] = useState(false);
|
||||
const [_isHistoryEditPending, setIsHistoryEditPending] = useState(false);
|
||||
const [isRenamingSession, setIsRenamingSession] = useState(false);
|
||||
const [sessionTitleDraft, setSessionTitleDraft] = useState("");
|
||||
const [loadedSessionTitle, setLoadedSessionTitle] = useState<string | null>(null);
|
||||
@@ -415,6 +419,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
Refine Further is a single-flight completed-summary turn. Guard synchronously with a ref so duplicate click, touch, or keyboard activations cannot submit a second refine request or close the active stream with a generation-in-progress error before React renders the disabled state.
|
||||
*/
|
||||
const refineSummaryInFlightRef = useRef(false);
|
||||
// FNXC:PlanningMode 2026-07-20-19:10: Validate→create is one logical action. Guard it outside React state so rapid double activation cannot invoke the task-created handoff twice even when the server correctly returns the same idempotent task.
|
||||
const validateCreateInFlightRef = useRef(false);
|
||||
// FNXC:PlanningMode 2026-07-20-20:15: Reloaded created sessions are terminal handoffs, not SummaryView drafts that can create another task.
|
||||
const restoredTaskHandoffRef = useRef<string | null>(null);
|
||||
const draftSessionIdRef = useRef<string | null>(null);
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-01-00:00:
|
||||
@@ -540,7 +548,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const isSessionListMode = showSessionList || (isCompactInterview && !mobileShowDetail);
|
||||
// FNXC:PlanningModeMobile 2026-07-20-10:30: Empty mobile state opens the composer because no saved destination exists; once sessions exist, every compact detail surface gets this single Back-to-list escape.
|
||||
const canReturnToSessionList = isCompactInterview && mobileShowDetail && planningSessions.length > 0;
|
||||
const [compactInterviewPane, setCompactInterviewPane] = useState<"question" | "plan" | "history">("question");
|
||||
const [refineFocus, setRefineFocus] = useState("");
|
||||
const { addToast } = useToast();
|
||||
const { pushNav } = useNavigationHistoryContext();
|
||||
|
||||
@@ -749,12 +757,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
setRunningSummary(summary);
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
resetPlanningAutoRetryBudget();
|
||||
setView({
|
||||
type: "summary",
|
||||
session: { sessionId, currentQuestion: null, summary },
|
||||
summary,
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
const inputPayload = JSON.parse(session.inputPayload ?? "{}") as { createdTaskId?: unknown };
|
||||
if (typeof inputPayload.createdTaskId === "string") {
|
||||
setView({ type: "task_created", taskId: inputPayload.createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
} else {
|
||||
setView({
|
||||
type: "error",
|
||||
@@ -991,12 +999,11 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
runningSummaryRef.current = normalizedSummary;
|
||||
setRunningSummary(normalizedSummary);
|
||||
setView((previous) => previous.type === "question"
|
||||
? {
|
||||
...previous,
|
||||
session: { ...previous.session, summary: normalizedSummary },
|
||||
}
|
||||
: previous);
|
||||
setView((previous) => previous.type === "loading"
|
||||
? { type: "plan_review", session: { sessionId, currentQuestion: null, summary: normalizedSummary }, summary: normalizedSummary }
|
||||
: previous.type === "question"
|
||||
? { ...previous, session: { ...previous.session, summary: normalizedSummary } }
|
||||
: previous);
|
||||
setStreamingOutput("");
|
||||
},
|
||||
onError: (message) => {
|
||||
@@ -1130,12 +1137,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
setView({
|
||||
type: "summary",
|
||||
session: { sessionId: session.id, currentQuestion: null, summary },
|
||||
summary,
|
||||
});
|
||||
setEditedSummary(summary);
|
||||
const inputPayload = JSON.parse(session.inputPayload ?? "{}") as { createdTaskId?: unknown };
|
||||
if (typeof inputPayload.createdTaskId === "string") {
|
||||
setView({ type: "task_created", taskId: inputPayload.createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId: session.id, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
} else {
|
||||
setView({
|
||||
type: "error",
|
||||
@@ -1434,6 +1441,8 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
: null;
|
||||
setView({ type: "initial" });
|
||||
} else if (session.status === "awaiting_input" && !session.currentQuestion && persistedRunningSummary) {
|
||||
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary: persistedRunningSummary }, summary: persistedRunningSummary });
|
||||
} else if (session.status === "awaiting_input" && session.currentQuestion) {
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
@@ -1458,10 +1467,14 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
const summary = persistedRunningSummary ?? normalizePlanningSummary(JSON.parse(session.result));
|
||||
setRunningSummary(summary);
|
||||
if (isValidatedPlanningSession(session)) {
|
||||
const createdTaskId = inputPayload && typeof inputPayload.createdTaskId === "string" ? inputPayload.createdTaskId : undefined;
|
||||
resetPlanningAutoRetryBudget();
|
||||
clearPlanningDescription(projectId);
|
||||
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||
setEditedSummary(summary);
|
||||
if (createdTaskId) {
|
||||
setView({ type: "task_created", taskId: createdTaskId });
|
||||
} else {
|
||||
setView({ type: "create_retry", session: { sessionId, currentQuestion: null, summary }, summary, errorMessage: t("planning.retryCreate", "Retry create") });
|
||||
}
|
||||
} else {
|
||||
setView({
|
||||
type: "error",
|
||||
@@ -1966,6 +1979,22 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
onClose();
|
||||
}, [flushDraftAndSummarize, initialPlan, onClose, projectId, resetMobileViewportAfterClose, view.type]);
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-20:15:
|
||||
A reload that discovers a linked task must complete the normal task-created handoff once.
|
||||
It must never reopen SummaryView, whose Create Task action would misrepresent terminal state.
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (view.type !== "task_created" || restoredTaskHandoffRef.current === view.taskId) return;
|
||||
const task = tasks.find((candidate) => candidate.id === view.taskId);
|
||||
if (!task) return;
|
||||
restoredTaskHandoffRef.current = task.id;
|
||||
onTaskCreated(task);
|
||||
clearPlanningActiveSession(projectId);
|
||||
setSelectedSessionId(null);
|
||||
handleClose();
|
||||
}, [handleClose, onTaskCreated, projectId, tasks, view]);
|
||||
|
||||
// Handle escape key to close
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
@@ -2033,15 +2062,10 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
try {
|
||||
// Submit response - AI will broadcast events via the already-connected stream
|
||||
const response = await respondToPlanning(sessionId, responses, projectId);
|
||||
// The stream normally drives this transition. Adopt an already-returned next question
|
||||
// when its event was lost so loading cannot later be replaced by a stale replay.
|
||||
if ("type" in response && response.type === "question" && response.data.id !== activeQuestion.id) {
|
||||
const nextQuestion = normalizeQuestionOptions(response.data);
|
||||
setView({
|
||||
type: "question",
|
||||
session: { sessionId, currentQuestion: nextQuestion, summary: runningSummaryRef.current },
|
||||
});
|
||||
}
|
||||
// FNXC:PlanningMode 2026-07-20-15:45: FN-8442 makes the SSE summary the
|
||||
// sole answer-turn transition authority. An answer updates the plan and clears
|
||||
// the question; only the explicit Refine request may publish another question.
|
||||
void response;
|
||||
} catch (err) {
|
||||
const errorMessage = getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response");
|
||||
/*
|
||||
@@ -2052,6 +2076,16 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
*/
|
||||
try {
|
||||
const persisted = await fetchAiSession(sessionId);
|
||||
if (persisted?.status === "awaiting_input" && !persisted.currentQuestion && persisted.result) {
|
||||
const history = parseConversationHistory(persisted.conversationHistory);
|
||||
const summary = normalizePlanningSummary(JSON.parse(persisted.result) as PlanningSummary);
|
||||
conversationHistoryRef.current = history;
|
||||
runningSummaryRef.current = summary;
|
||||
setConversationHistory(history);
|
||||
setRunningSummary(summary);
|
||||
setView({ type: "plan_review", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||
return;
|
||||
}
|
||||
if (persisted?.status === "awaiting_input" && persisted.currentQuestion) {
|
||||
const history = parseConversationHistory(persisted.conversationHistory);
|
||||
const summary = persisted.result
|
||||
@@ -2134,20 +2168,64 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
Validation belongs to the always-visible running-plan pane, not the center question state. A user
|
||||
may finalize during loading or recoverable error states; the server cancels an active turn safely.
|
||||
*/
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
const sessionId = selectedSessionId;
|
||||
if (!sessionId) return;
|
||||
const handleRefineFromPlan = useCallback(async () => {
|
||||
if (view.type !== "plan_review") return;
|
||||
setError(null);
|
||||
setView({ type: "loading" });
|
||||
try {
|
||||
const result = await validatePlanningSession(sessionId, projectId);
|
||||
const summary = normalizePlanningSummary(result.summary);
|
||||
setEditedSummary(summary);
|
||||
setRunningSummary(summary);
|
||||
setView({ type: "summary", session: { sessionId, currentQuestion: null, summary }, summary });
|
||||
await respondToPlanning(view.session.sessionId, { refine: true, ...(refineFocus.trim() ? { focus: refineFocus.trim() } : {}) }, projectId);
|
||||
setRefineFocus("");
|
||||
} catch (err) {
|
||||
setError(getErrorMessage(err) || t("planning.failedValidatePlan", "Failed to validate plan"));
|
||||
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to refine plan"));
|
||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||
}
|
||||
}, [projectId, selectedSessionId, t]);
|
||||
}, [projectId, refineFocus, t, view]);
|
||||
|
||||
const handleValidatePlan = useCallback(async () => {
|
||||
if (view.type !== "plan_review" || validateCreateInFlightRef.current) return;
|
||||
validateCreateInFlightRef.current = true;
|
||||
setError(null);
|
||||
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
||||
try {
|
||||
try {
|
||||
await validatePlanningSession(view.session.sessionId, projectId);
|
||||
} catch (err) {
|
||||
// A lost validation response is ambiguous, but a rejected validation must not strand
|
||||
// the user in create-only retry: inspect durable state before choosing the next phase.
|
||||
const persisted = await fetchAiSession(view.session.sessionId).catch(() => null);
|
||||
if (persisted && isValidatedPlanningSession(persisted)) {
|
||||
setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
} else {
|
||||
setError(getErrorMessage(err) || t("planning.failedCreateTask", "Failed to validate plan"));
|
||||
setView({ type: "plan_review", session: view.session, summary: view.summary });
|
||||
}
|
||||
return;
|
||||
}
|
||||
const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) });
|
||||
onTaskCreated(task);
|
||||
clearPlanningActiveSession(projectId);
|
||||
setSelectedSessionId(null);
|
||||
handleClose();
|
||||
} catch (err) {
|
||||
setView({ type: "create_retry", session: view.session, summary: view.summary, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
} finally {
|
||||
validateCreateInFlightRef.current = false;
|
||||
}
|
||||
}, [handleClose, onTaskCreated, projectId, t, view, workflowId]);
|
||||
|
||||
const handleRetryCreateTask = useCallback(async () => {
|
||||
if (view.type !== "create_retry" || validateCreateInFlightRef.current) return;
|
||||
validateCreateInFlightRef.current = true;
|
||||
setView({ type: "creating_task", session: view.session, summary: view.summary });
|
||||
try {
|
||||
const task = await createTaskFromPlanning(view.session.sessionId, view.summary, projectId, { ...(workflowId !== undefined ? { workflowId } : {}) });
|
||||
onTaskCreated(task); clearPlanningActiveSession(projectId); setSelectedSessionId(null); handleClose();
|
||||
} catch (err) {
|
||||
setView({ ...view, errorMessage: getErrorMessage(err) || t("planning.failedCreateTask", "Failed to create task") });
|
||||
} finally {
|
||||
validateCreateInFlightRef.current = false;
|
||||
}
|
||||
}, [handleClose, onTaskCreated, projectId, t, view, workflowId]);
|
||||
|
||||
const handleCreateTask = useCallback(async () => {
|
||||
if (view.type !== "summary") return;
|
||||
@@ -2266,7 +2344,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
}
|
||||
}, [baseBranch, branchMode, branchName, handleClose, view, onTasksCreated, projectId, workflowId]);
|
||||
|
||||
const handleSelectAnsweredQuestion = useCallback(async (entry: ConversationHistoryEntry) => {
|
||||
const _handleSelectAnsweredQuestion = useCallback(async (entry: ConversationHistoryEntry) => {
|
||||
const questionId = entry.question?.id;
|
||||
if (view.type !== "question" || !questionId) return;
|
||||
setError(null);
|
||||
@@ -2383,7 +2461,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
synchronized on both transitions so a second Sessions press cannot leave the question pane
|
||||
hidden by the mobile list CSS.
|
||||
*/}
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error") && (
|
||||
{selectedSessionId && (view.type === "question" || view.type === "loading" || view.type === "error" || view.type === "plan_review" || view.type === "create_retry") && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
@@ -2408,20 +2486,9 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
<div
|
||||
className={`planning-modal-body planning-modal-body--split ${
|
||||
isSessionListMode ? "planning-modal-body--show-list" : "planning-modal-body--show-detail"
|
||||
} ${
|
||||
selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview
|
||||
? `planning-modal-body--compact-interview planning-modal-body--compact-${compactInterviewPane}`
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") ? (
|
||||
<AnsweredQuestionHistory
|
||||
entries={conversationHistory}
|
||||
selectedQuestionId={editingQuestionId ?? (view.type === "question" ? view.session.currentQuestion?.id : undefined)}
|
||||
isPending={isHistoryEditPending}
|
||||
onSelect={(entry) => void handleSelectAnsweredQuestion(entry)}
|
||||
/>
|
||||
) : (
|
||||
{isSessionListMode && (
|
||||
<PlanningSessionList
|
||||
sessions={planningSessions}
|
||||
loading={sessionsLoading}
|
||||
@@ -2439,11 +2506,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
/>
|
||||
)}
|
||||
|
||||
{/*
|
||||
FNXC:Planning 2026-06-23-02:00:
|
||||
Sidebar resize handle — parity with MissionManager's mission-manager__sidebar-resize-handle. Rendered only on desktop (sidebar stacks on mobile). Pointer-drag and arrow-key resize both clamp + persist width.
|
||||
*/}
|
||||
{viewportMode === "desktop" && !isShortViewport() && (
|
||||
{isSessionListMode && viewportMode === "desktop" && !isShortViewport() && (
|
||||
<div
|
||||
className="planning-sidebar-resize-handle"
|
||||
role="separator"
|
||||
@@ -2458,20 +2521,6 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && isCompactInterview && (
|
||||
<nav className="planning-compact-pane-switcher" aria-label={t("planning.interviewPanels", "Planning interview panels")}>
|
||||
<button type="button" className={`btn ${compactInterviewPane === "question" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "question"} onClick={() => setCompactInterviewPane("question")}>
|
||||
{t("planning.question", "Question")}
|
||||
</button>
|
||||
<button type="button" className={`btn ${compactInterviewPane === "plan" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "plan"} onClick={() => setCompactInterviewPane("plan")}>
|
||||
{t("planning.runningPlan", "Running plan")}
|
||||
</button>
|
||||
<button type="button" className={`btn ${compactInterviewPane === "history" ? "btn-primary" : ""}`} aria-pressed={compactInterviewPane === "history"} onClick={() => setCompactInterviewPane("history")}>
|
||||
{t("planning.answeredQuestions", "Answered questions")}
|
||||
</button>
|
||||
</nav>
|
||||
)}
|
||||
|
||||
<div className="planning-detail">
|
||||
{error && <div className="form-error planning-error">{error}</div>}
|
||||
{/*
|
||||
@@ -2749,6 +2798,39 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "plan_review" && (
|
||||
<div className="planning-summary" data-testid="planning-plan-review">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<h4>{view.summary.title}</h4>
|
||||
<p>{view.summary.description}</p>
|
||||
{view.summary.keyDeliverables.length > 0 && <ul>{view.summary.keyDeliverables.map((item) => <li key={item}>{item}</li>)}</ul>}
|
||||
<label htmlFor="planning-refine-focus">{t("planning.refineFocus", "Focus the next question (optional)")}</label>
|
||||
<input id="planning-refine-focus" data-testid="planning-refine-focus" className="input" value={refineFocus} onChange={(event) => setRefineFocus(event.target.value)} placeholder={t("planning.refineFocusPlaceholder", "For example: security, real-time updates, or a specific question")}/>
|
||||
<div className="planning-summary-actions">
|
||||
<button type="button" className="btn" onClick={() => void handleRefineFromPlan()}>{t("planning.refine", "Refine")}</button>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleValidatePlan()}>{t("planning.validatePlan", "Validate")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "creating_task" && <div className="planning-loading"><Loader2 size={24} className="spin" /> {t("planning.creatingTask", "Creating task…")}</div>}
|
||||
{view.type === "task_created" && (
|
||||
<div className="planning-loading" data-testid="planning-task-created"><CheckCircle size={24} /> {t("planning.taskCreated", "Task created")}</div>
|
||||
)}
|
||||
{view.type === "create_retry" && (
|
||||
<div className="planning-summary" data-testid="planning-create-retry">
|
||||
<div className="planning-view-scroll planning-summary-scroll">
|
||||
<h4>{view.summary.title}</h4>
|
||||
<p>{view.summary.description}</p>
|
||||
<div className="ai-error-panel" role="alert">
|
||||
<div className="ai-error-message">{view.errorMessage}</div>
|
||||
<button type="button" className="btn btn-primary" onClick={() => void handleRetryCreateTask()}>{t("planning.retryCreate", "Retry create")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.type === "summary" && editedSummary && (
|
||||
<SummaryView
|
||||
summary={editedSummary}
|
||||
@@ -2793,97 +2875,12 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/*
|
||||
FNXC:PlanningModeMobile 2026-07-20-10:30:
|
||||
FN-8427 keeps the running plan through question, loading, and recoverable-error detail
|
||||
states, but unmounts it in session-list mode. The list must own the entire compact body;
|
||||
leaving this pane mounted reduced rows to the New session footer on mobile.
|
||||
*/}
|
||||
{selectedSessionId && !isSessionListMode && (view.type === "question" || view.type === "loading" || view.type === "error") && (
|
||||
<RunningPlanPane
|
||||
summary={runningSummary}
|
||||
fallbackDescription={activePlanPrompt}
|
||||
onValidate={() => void handleValidatePlan()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-19-12:00:
|
||||
The active-session left pane is answer history, not a second copy of the interview. Selecting a row
|
||||
uses the server's question-id edit-and-branch contract, while the center remains the sole answer editor.
|
||||
*/
|
||||
function AnsweredQuestionHistory({ entries, selectedQuestionId, isPending, onSelect }: {
|
||||
entries: ConversationHistoryEntry[];
|
||||
selectedQuestionId?: string | null;
|
||||
isPending: boolean;
|
||||
onSelect: (entry: ConversationHistoryEntry) => void;
|
||||
}) {
|
||||
const { t } = useTranslation("app");
|
||||
const answered = entries.filter((entry) => entry.question && entry.response);
|
||||
return (
|
||||
<aside className="planning-sidebar planning-answered-history" aria-label={t("planning.answeredQuestions", "Answered questions")}>
|
||||
<div className="planning-sidebar-header"><h4>{t("planning.answeredQuestions", "Answered questions")}</h4></div>
|
||||
<div className="planning-session-list" {...(answered.length > 0 ? { "data-testid": "conversation-history" } : {})}>
|
||||
{answered.map((entry) => (
|
||||
<button
|
||||
key={entry.question!.id}
|
||||
type="button"
|
||||
className={`btn planning-answered-history-edit ${selectedQuestionId === entry.question!.id ? "active" : ""}`}
|
||||
onClick={() => onSelect(entry)}
|
||||
disabled={isPending}
|
||||
aria-label={t("planning.editAnswer", "Edit answer for {{question}}", { question: entry.question!.question })}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
<span>{entry.question!.question}</span>
|
||||
<span className="planning-answered-history-response">{formatHistoryResponse(entry)}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-19-12:00:
|
||||
Keep the running plan mounted beside every interview state, including loading and errors. Validation is
|
||||
an intentional user action; no generation state offers a competing completion path.
|
||||
*/
|
||||
function formatHistoryResponse(entry: ConversationHistoryEntry): string {
|
||||
const response = entry.response ?? {};
|
||||
const options = entry.question?.options ?? [];
|
||||
const optionLabel = (value: string | number | boolean): string => {
|
||||
const matchingOption = typeof value === "string" ? options.find((option) => option.id === value) : undefined;
|
||||
return matchingOption?.label ?? String(value);
|
||||
};
|
||||
return Object.entries(response)
|
||||
.filter(([key]) => key !== "_comment")
|
||||
.flatMap(([, value]) => Array.isArray(value) ? value : [value])
|
||||
.filter((value): value is string | number | boolean => typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
.map(optionLabel)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
function RunningPlanPane({ summary, fallbackDescription, onValidate }: { summary?: PlanningSummary | null; fallbackDescription: string; onValidate?: () => void }) {
|
||||
const { t } = useTranslation("app");
|
||||
const plan = normalizePlanningSummary(summary ?? { title: "", description: fallbackDescription, suggestedSize: "M", priority: DEFAULT_TASK_PRIORITY, suggestedDependencies: [], keyDeliverables: [] });
|
||||
return (
|
||||
<aside className="planning-running-plan" aria-label={t("planning.runningPlan", "Running plan")}>
|
||||
<h4>{t("planning.runningPlan", "Running plan")}</h4>
|
||||
<div className="planning-running-plan-content">
|
||||
<h5>{plan.title}</h5>
|
||||
<p>{plan.description}</p>
|
||||
{plan.keyDeliverables.length > 0 && <ul>{plan.keyDeliverables.map((item) => <li key={item}>{item}</li>)}</ul>}
|
||||
</div>
|
||||
{onValidate && <button type="button" className="btn btn-primary" onClick={onValidate}>{t("planning.validatePlan", "Validate plan")}</button>}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
interface QuestionFormProps {
|
||||
question: PlanningQuestion;
|
||||
initialResponse?: QuestionResponse;
|
||||
@@ -3230,7 +3227,7 @@ function QuestionForm({ question: rawQuestion, initialResponse, onSubmit }: Ques
|
||||
onClick={handleSubmit}
|
||||
disabled={!isValid()}
|
||||
>
|
||||
{t("planning.nextQuestion", "Next question")}
|
||||
{t("planning.continueToPlan", "Continue to plan")}
|
||||
<ArrowRight size={16} className="icon-ml-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,987 +1,12 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { act, render, renderHook, screen, fireEvent, waitFor, within } from "@testing-library/react";
|
||||
import * as api from "../../api";
|
||||
import { PlanningModeModal } from "../PlanningModeModal";
|
||||
import { TaskDetailModal } from "../TaskDetailModal";
|
||||
|
||||
const mockAddToast = vi.fn();
|
||||
|
||||
vi.mock("../../hooks/useToast", () => ({
|
||||
useOptionalToast: () => null,
|
||||
useToast: () => ({
|
||||
addToast: mockAddToast,
|
||||
removeToast: vi.fn(),
|
||||
toasts: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||
return {
|
||||
...actual,
|
||||
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||
};
|
||||
});
|
||||
import type { MergeResult } from "@fusion/core";
|
||||
import {
|
||||
mockStartPlanning,
|
||||
mockStartPlanningStreaming,
|
||||
mockCreatePlanningDraft,
|
||||
mockConnectPlanningStream,
|
||||
mockRespondToPlanning,
|
||||
mockRetryPlanningSession,
|
||||
mockCancelPlanning,
|
||||
mockStopPlanningGeneration,
|
||||
mockUpdatePlanningSessionDraft,
|
||||
mockCreateTaskFromPlanning,
|
||||
mockValidatePlanningSession,
|
||||
mockStartPlanningBreakdown,
|
||||
mockCreateTasksFromPlanning,
|
||||
mockFetchAiSession,
|
||||
mockParseConversationHistory,
|
||||
mockFetchModels,
|
||||
mockAcquireSessionLock,
|
||||
mockReleaseSessionLock,
|
||||
mockForceAcquireSessionLock,
|
||||
mockUploadAttachment,
|
||||
mockDeleteAttachment,
|
||||
mockUpdateTask,
|
||||
mockPauseTask,
|
||||
mockUnpauseTask,
|
||||
mockFetchTaskDetail,
|
||||
mockRequestSpecRevision,
|
||||
mockApprovePlan,
|
||||
mockRejectPlan,
|
||||
mockRefineTask,
|
||||
mockFetchAiSessions,
|
||||
mockConfirm,
|
||||
mockUseViewportMode,
|
||||
mockUseMobileKeyboard,
|
||||
mockTasks,
|
||||
mockModels,
|
||||
mockQuestion,
|
||||
mockSummary,
|
||||
mockTaskDetail,
|
||||
MockEventSource,
|
||||
getMediaBlocks,
|
||||
mockViewport,
|
||||
} from "./PlanningModeModal.test-helpers";
|
||||
|
||||
vi.mock("../../api", () => ({
|
||||
startPlanning: (...args: any[]) => mockStartPlanning(...args),
|
||||
startPlanningStreaming: (...args: any[]) => mockStartPlanningStreaming(...args),
|
||||
createPlanningDraft: (...args: any[]) => mockCreatePlanningDraft(...args),
|
||||
connectPlanningStream: (...args: any[]) => mockConnectPlanningStream(...args),
|
||||
respondToPlanning: (...args: any[]) => mockRespondToPlanning(...args),
|
||||
retryPlanningSession: (...args: any[]) => mockRetryPlanningSession(...args),
|
||||
cancelPlanning: (...args: any[]) => mockCancelPlanning(...args),
|
||||
stopPlanningGeneration: (...args: any[]) => mockStopPlanningGeneration(...args),
|
||||
updatePlanningSessionDraft: (...args: any[]) => mockUpdatePlanningSessionDraft(...args),
|
||||
createTaskFromPlanning: (...args: any[]) => mockCreateTaskFromPlanning(...args),
|
||||
validatePlanningSession: (...args: any[]) => mockValidatePlanningSession(...args),
|
||||
startPlanningBreakdown: (...args: any[]) => mockStartPlanningBreakdown(...args),
|
||||
createTasksFromPlanning: (...args: any[]) => mockCreateTasksFromPlanning(...args),
|
||||
fetchAiSession: (...args: any[]) => mockFetchAiSession(...args),
|
||||
parseConversationHistory: (...args: any[]) => mockParseConversationHistory(...args),
|
||||
acquireSessionLock: (...args: any[]) => mockAcquireSessionLock(...args),
|
||||
releaseSessionLock: (...args: any[]) => mockReleaseSessionLock(...args),
|
||||
forceAcquireSessionLock: (...args: any[]) => mockForceAcquireSessionLock(...args),
|
||||
uploadAttachment: (...args: any[]) => mockUploadAttachment(...args),
|
||||
deleteAttachment: (...args: any[]) => mockDeleteAttachment(...args),
|
||||
updateTask: (...args: any[]) => mockUpdateTask(...args),
|
||||
pauseTask: (...args: any[]) => mockPauseTask(...args),
|
||||
unpauseTask: (...args: any[]) => mockUnpauseTask(...args),
|
||||
fetchTaskDetail: (...args: any[]) => mockFetchTaskDetail(...args),
|
||||
requestSpecRevision: (...args: any[]) => mockRequestSpecRevision(...args),
|
||||
approvePlan: (...args: any[]) => mockApprovePlan(...args),
|
||||
rejectPlan: (...args: any[]) => mockRejectPlan(...args),
|
||||
refineTask: (...args: any[]) => mockRefineTask(...args),
|
||||
fetchSettings: vi.fn().mockResolvedValue({ modelPresets: [], autoSelectModelPreset: false, defaultPresetBySize: {} }),
|
||||
/*
|
||||
FNXC:PlanningModeSettings 2026-07-18-10:50:
|
||||
Sync-settle clarification settings so Start Planning is not racey under full-suite load.
|
||||
*/
|
||||
fetchGlobalSettings: vi.fn(() => {
|
||||
const settled = {
|
||||
then(onFulfilled: (settings: { agentClarificationEnabled: boolean }) => unknown) {
|
||||
onFulfilled({ agentClarificationEnabled: false });
|
||||
return settled;
|
||||
},
|
||||
catch() {
|
||||
return settled;
|
||||
},
|
||||
finally(onFinally: () => unknown) {
|
||||
onFinally();
|
||||
return settled;
|
||||
},
|
||||
};
|
||||
return settled;
|
||||
}),
|
||||
fetchModels: (...args: any[]) => mockFetchModels(...args),
|
||||
fetchWorkflowSteps: vi.fn().mockResolvedValue([]),
|
||||
refineText: vi.fn(),
|
||||
getRefineErrorMessage: vi.fn((err: any) => err?.message || "Failed to refine"),
|
||||
updateGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||
duplicateTask: vi.fn().mockResolvedValue({}),
|
||||
fetchAiSessions: (...args: any[]) => mockFetchAiSessions(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useConfirm", () => ({
|
||||
useConfirm: () => ({ confirm: mockConfirm }),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useViewportMode", () => ({
|
||||
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||
isFullScreenSheetViewport: () => false,
|
||||
isShortViewport: () => false,
|
||||
getViewportMode: () => mockUseViewportMode(),
|
||||
isMobileViewport: () => mockUseViewportMode() === "mobile",
|
||||
useViewportMode: () => mockUseViewportMode(),
|
||||
}));
|
||||
|
||||
vi.mock("../../hooks/useMobileKeyboard", () => ({
|
||||
useMobileKeyboard: (...args: any[]) => mockUseMobileKeyboard(...args),
|
||||
}));
|
||||
|
||||
describe("PlanningModeModal", () => {
|
||||
const mockOnClose = vi.fn();
|
||||
const mockOnTaskCreated = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAddToast.mockReset();
|
||||
mockConfirm.mockReset();
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
MockEventSource.reset();
|
||||
vi.stubGlobal("EventSource", MockEventSource as any);
|
||||
window.sessionStorage.clear();
|
||||
// Default to desktop viewport; mobile-specific tests override per-test.
|
||||
mockViewport("desktop");
|
||||
|
||||
// Default mock for streaming
|
||||
mockStartPlanningStreaming.mockResolvedValue({ sessionId: "session-123" });
|
||||
// Server's createDraftSession always returns the placeholder title; the
|
||||
// real summarized title only arrives later via blur/close summarize or
|
||||
// when the session transitions out of draft. Mirror that in the mock so
|
||||
// the sidebar render rule (preview while title === placeholder) behaves
|
||||
// realistically in tests.
|
||||
mockCreatePlanningDraft.mockResolvedValue({ sessionId: "draft-123", title: "New planning session" });
|
||||
mockRetryPlanningSession.mockResolvedValue({ success: true, sessionId: "session-123" });
|
||||
mockValidatePlanningSession.mockResolvedValue({ summary: mockSummary, validated: true });
|
||||
mockStartPlanningBreakdown.mockResolvedValue({ sessionId: "session-123", subtasks: [] });
|
||||
mockFetchAiSession.mockResolvedValue(null);
|
||||
mockFetchAiSessions.mockResolvedValue([]);
|
||||
mockParseConversationHistory.mockImplementation((raw: string) => {
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
mockFetchModels.mockResolvedValue({
|
||||
models: mockModels,
|
||||
favoriteProviders: [],
|
||||
favoriteModels: [],
|
||||
resolvedPlanningProvider: "openai",
|
||||
resolvedPlanningModelId: "gpt-4o",
|
||||
});
|
||||
mockAcquireSessionLock.mockResolvedValue({ acquired: true, currentHolder: null });
|
||||
mockReleaseSessionLock.mockResolvedValue(undefined);
|
||||
mockForceAcquireSessionLock.mockResolvedValue(undefined);
|
||||
mockCancelPlanning.mockResolvedValue(undefined);
|
||||
mockUpdatePlanningSessionDraft.mockResolvedValue({ ok: true });
|
||||
mockStopPlanningGeneration.mockResolvedValue({ success: true });
|
||||
|
||||
// Default: simulate receiving a question after a brief delay
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.(mockQuestion);
|
||||
}, 10);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-04-17:04:
|
||||
The draft-creation debounce tests assert a NEGATIVE (a 300ms debounce interval elapsing without spawning a duplicate
|
||||
createPlanningDraft). They previously did this with real-time `setTimeout(350)` sleeps, burning ~2.1s of wall-clock per
|
||||
run for zero added signal (FN-5048: do not add slow tests). Those tests now drive fake timers via
|
||||
`vi.advanceTimersByTimeAsync`, which advances the debounce deterministically and flushes the mock's promise
|
||||
microtasks between timers. This afterEach restores real timers so the remaining real-timer + waitFor tests are unaffected.
|
||||
*/
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("Initial view", () => {
|
||||
it("renders the initial input view when open", () => {
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText("Planning Mode")).toBeDefined();
|
||||
expect(screen.getByPlaceholderText(/e.g., Build a user authentication/)).toBeDefined();
|
||||
expect(container.querySelector(".planning-modal-body")).not.toBeNull();
|
||||
expect(container.querySelector(".planning-modal-body")?.classList.contains("modal-body")).toBe(false);
|
||||
expect(container.querySelector(".planning-examples-label")?.textContent).toBe("Try an example:");
|
||||
});
|
||||
|
||||
it("does not render when closed", () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={false}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByText("Planning Mode")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not auto-focus the initial textarea on mobile open until the user focuses it", () => {
|
||||
mockViewport("mobile");
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
|
||||
act(() => {
|
||||
textarea.focus();
|
||||
});
|
||||
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
});
|
||||
|
||||
it("does not auto-focus the initial textarea in embedded desktop presentation", () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
initialPlan={undefined}
|
||||
presentation="embedded"
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("");
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
});
|
||||
|
||||
it("auto-starts populated initialPlan handoffs without focusing the initial textarea", async () => {
|
||||
const focusSpy = vi.spyOn(HTMLTextAreaElement.prototype, "focus");
|
||||
|
||||
try {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
initialPlan="Build a login system from handoff"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from handoff", undefined, undefined, {
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
});
|
||||
|
||||
expect(focusSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
focusSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("focuses the initial textarea when New session is clicked while already composing", () => {
|
||||
const rafSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
expect(document.activeElement).not.toBe(textarea);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
expect(rafSpy).toHaveBeenCalled();
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
} finally {
|
||||
rafSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("resets a selected desktop session to compose view and focuses New session", async () => {
|
||||
const rafSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
mockFetchAiSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-existing",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Existing session",
|
||||
preview: "An existing planning session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
mockFetchAiSession.mockResolvedValue({
|
||||
id: "session-existing",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Existing session",
|
||||
inputPayload: JSON.stringify({ initialPlan: "Existing selected plan" }),
|
||||
conversationHistory: "[]",
|
||||
currentQuestion: null,
|
||||
result: JSON.stringify(mockSummary),
|
||||
error: null,
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(await screen.findByText("Existing session"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchAiSession).toHaveBeenCalledWith("session-existing");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sessions" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
expect(textarea.value).toBe("");
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
expect(screen.getByRole("button", { name: /Start Planning/ })).toBeDisabled();
|
||||
} finally {
|
||||
rafSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("shows the mobile detail pane and focuses compose when New session is clicked from the list", async () => {
|
||||
mockViewport("mobile");
|
||||
const rafSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
mockFetchAiSessions.mockResolvedValue([
|
||||
{
|
||||
id: "session-mobile",
|
||||
type: "planning",
|
||||
status: "complete",
|
||||
title: "Mobile session",
|
||||
preview: "A mobile planning session",
|
||||
projectId: null,
|
||||
lockedByTab: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
archived: false,
|
||||
},
|
||||
]);
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
await screen.findByText("Mobile session");
|
||||
const body = container.querySelector(".planning-modal-body");
|
||||
await waitFor(() => {
|
||||
expect(body?.classList.contains("planning-modal-body--show-list")).toBe(true);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
expect(body?.classList.contains("planning-modal-body--show-detail")).toBe(true);
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
} finally {
|
||||
rafSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves existing compose draft text and moves the caret to the end on New session focus", () => {
|
||||
const rafSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
try {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByLabelText("What do you want to build?") as HTMLTextAreaElement;
|
||||
fireEvent.change(textarea, { target: { value: "Keep this restored draft" } });
|
||||
textarea.setSelectionRange(0, 0);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "New session" }));
|
||||
|
||||
expect(textarea.value).toBe("Keep this restored draft");
|
||||
expect(document.activeElement).toBe(textarea);
|
||||
expect(textarea.selectionStart).toBe(textarea.value.length);
|
||||
expect(textarea.selectionEnd).toBe(textarea.value.length);
|
||||
} finally {
|
||||
rafSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("mobile close path blurs focused input and resets viewport scroll", () => {
|
||||
mockViewport("mobile");
|
||||
const scrollToSpy = vi.spyOn(window, "scrollTo").mockImplementation(() => undefined);
|
||||
const rafSpy = vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/) as HTMLTextAreaElement;
|
||||
act(() => {
|
||||
textarea.focus();
|
||||
});
|
||||
const blurSpy = vi.spyOn(textarea, "blur");
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close" }));
|
||||
});
|
||||
|
||||
expect(blurSpy).toHaveBeenCalledTimes(1);
|
||||
expect(scrollToSpy).toHaveBeenCalledWith(0, 0);
|
||||
expect(rafSpy).toHaveBeenCalled();
|
||||
expect(mockOnClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("hides send to background button in initial state", () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.queryByLabelText("Send to background")).toBeNull();
|
||||
});
|
||||
|
||||
it("enables start button when text is entered", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const startButton = screen.getByText("Start Planning");
|
||||
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(true);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Test plan" } });
|
||||
|
||||
expect(startButton.closest("button")?.hasAttribute("disabled")).toBe(false);
|
||||
});
|
||||
|
||||
it("shows example chips", () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByText(/Build a user authentication/)).toBeDefined();
|
||||
});
|
||||
|
||||
it("renders planning model dropdown in initial view", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Advanced planning settings" })).toBeDefined();
|
||||
expect(screen.queryByRole("button", { name: "Planning Model" })).toBeNull();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
|
||||
const modelTrigger = screen.getByRole("button", { name: "Planning Model" });
|
||||
expect(modelTrigger).toBeDefined();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("shows resolved default model badge and switches to override badge when selected", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
expect(screen.getByText("openai/gpt-4o")).toBeDefined();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
|
||||
|
||||
expect(screen.getByText("anthropic/claude-sonnet-4-5")).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes selected planning model to startPlanningStreaming", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetchModels).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Planning Model" }));
|
||||
fireEvent.click(screen.getByRole("option", { name: /Claude Sonnet 4.5/ }));
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, {
|
||||
planningModelProvider: "anthropic",
|
||||
planningModelId: "claude-sonnet-4-5",
|
||||
thinkingLevel: undefined,
|
||||
}, {
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps advanced disclosure collapsed by default and reveals controls when expanded", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const disclosureButton = screen.getByRole("button", { name: "Advanced planning settings" });
|
||||
expect(disclosureButton).toBeDefined();
|
||||
|
||||
const disclosure = disclosureButton.closest(".onboarding-disclosure");
|
||||
expect(disclosure).not.toBeNull();
|
||||
const disclosureScope = within(disclosure as HTMLElement);
|
||||
|
||||
expect(disclosureButton.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(disclosureScope.queryByRole("button", { name: "Planning Model" })).toBeNull();
|
||||
expect(disclosureScope.queryByText(/Selects which model runs the planning interview/)).toBeNull();
|
||||
|
||||
fireEvent.click(disclosureButton);
|
||||
expect(disclosureButton.getAttribute("aria-expanded")).toBe("true");
|
||||
|
||||
expect(disclosureScope.getByRole("button", { name: "Planning Model" })).toBeDefined();
|
||||
await waitFor(() => {
|
||||
expect(disclosureScope.getByText("openai/gpt-4o")).toBeDefined();
|
||||
});
|
||||
expect(disclosureScope.getByText(/Selects which model runs the planning interview/)).toBeDefined();
|
||||
expect(disclosureScope.queryByLabelText("Allow follow-up clarification questions")).toBeNull();
|
||||
});
|
||||
|
||||
it("calls startPlanningStreaming without model override when none selected", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build auth system" } });
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build auth system", undefined, undefined, {
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
it("auto-creates a draft after typing and reuses it when starting", async () => {
|
||||
// FNXC:PlanningMode 2026-07-04-17:04: fake timers drive the 300ms create-draft debounce deterministically
|
||||
// (advanceTimersByTimeAsync flushes the mock promise between timers), replacing real-time sleeps.
|
||||
vi.useFakeTimers();
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan" } });
|
||||
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledWith(
|
||||
"Build a detailed auth system plan",
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Sidebar shows the inputPayload-derived preview for draft rows so
|
||||
// multiple drafts are distinguishable, not the placeholder title that
|
||||
// createDraftSession returns. The text also appears in the textarea
|
||||
// value, so scope the query to the sidebar item title element.
|
||||
const sidebarItem = document.querySelector(".planning-sidebar-item-title");
|
||||
expect(sidebarItem?.textContent).toBe("Build a detailed auth system plan");
|
||||
|
||||
fireEvent.change(textarea, { target: { value: "Build a detailed auth system plan with extras" } });
|
||||
// Let the debounce interval elapse; the existing draft must be reused, not re-created.
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
});
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith(
|
||||
"Build a detailed auth system plan with extras",
|
||||
undefined,
|
||||
undefined,
|
||||
{
|
||||
clarificationEnabled: true,
|
||||
},
|
||||
"draft-123",
|
||||
);
|
||||
});
|
||||
|
||||
// FNXC:PlanningMode 2026-07-01-00:00: regression — deliberate typing must not spawn one draft per keystroke.
|
||||
// Original symptom: each character created a new draft while the create-draft request was in flight, because
|
||||
// the create-suppression guard only checked draftSessionIdRef, which is populated after the round-trip resolves.
|
||||
it("creates exactly one draft when keystrokes arrive while the create request is still in flight", async () => {
|
||||
// FNXC:PlanningMode 2026-07-04-17:04: fake timers drive the 300ms debounce deterministically; the in-flight
|
||||
// create stays unresolved (resolveCreate) so the suppression sentinel is what collapses keystrokes to one create.
|
||||
vi.useFakeTimers();
|
||||
let resolveCreate: ((value: { sessionId: string; title: string }) => void) | undefined;
|
||||
mockCreatePlanningDraft.mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolveCreate = resolve;
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
const textarea = screen.getByPlaceholderText(/e.g., Build a user authentication/);
|
||||
|
||||
// First keystroke → debounce (300ms) fires the create; it stays in flight (unresolved).
|
||||
fireEvent.change(textarea, { target: { value: "Build" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Subsequent keystrokes while the create is still in flight must be suppressed by the
|
||||
// synchronous in-flight sentinel — not each spawn another draft.
|
||||
fireEvent.change(textarea, { target: { value: "Build a" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: "Build an" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
fireEvent.change(textarea, { target: { value: "Build an auth" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Once the create resolves and further edits arrive, they patch the single draft — no new create.
|
||||
resolveCreate?.({ sessionId: "draft-123", title: "New planning session" });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(document.querySelector(".planning-sidebar-item-title")).not.toBeNull();
|
||||
fireEvent.change(textarea, { target: { value: "Build an auth system" } });
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(350);
|
||||
});
|
||||
expect(mockCreatePlanningDraft).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("auto-starts planning when initialPlan prop is provided", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
initialPlan="Build a login system from new task dialog"
|
||||
/>
|
||||
);
|
||||
|
||||
// Wait for startPlanningStreaming to be called (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Build a login system from new task dialog", undefined, undefined, {
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
}, { timeout: 2000 });
|
||||
|
||||
// Should transition to question view
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("What is the scope?")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("sets initial plan text in textarea when initialPlan prop is provided", async () => {
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
initialPlan="Pre-filled plan from new task"
|
||||
/>
|
||||
);
|
||||
|
||||
// The auto-start should happen with the initial plan (allow time for setTimeout in useEffect)
|
||||
await waitFor(() => {
|
||||
expect(mockStartPlanningStreaming).toHaveBeenCalledWith("Pre-filled plan from new task", undefined, undefined, {
|
||||
clarificationEnabled: true,
|
||||
}, undefined);
|
||||
}, { timeout: 2000 });
|
||||
});
|
||||
|
||||
it("shows streamed thinking in loading view before first question arrives", async () => {
|
||||
mockConnectPlanningStream.mockImplementation((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onThinking?.("Analyzing requirements...");
|
||||
}, 0);
|
||||
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Advanced planning settings" }));
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Draft a migration plan" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("AI is thinking...")).toBeDefined();
|
||||
expect(screen.getByText("Analyzing requirements...")).toBeDefined();
|
||||
});
|
||||
|
||||
const loader = document.querySelector(".planning-loading .spin");
|
||||
expect(loader).not.toBeNull();
|
||||
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
/*
|
||||
FNXC:LoadingIndicators 2026-06-25-12:05:
|
||||
Planning's first-paint spinner contract follows the shared collision-proof dashboard keyframe name. Do not require the obsolete global `spin` keyframe here; lazy CSS chunks can redefine generic keyframes.
|
||||
*/
|
||||
expect(css).toMatch(/\.spin\s*\{[^}]*animation:\s*fusion-spinner-spin\s+1s\s+linear\s+infinite;/);
|
||||
|
||||
expect(screen.getByRole("button", { name: "Hide thinking" })).toBeDefined();
|
||||
expect(document.querySelector(".planning-thinking-output")?.textContent).toContain("Analyzing requirements...");
|
||||
});
|
||||
});
|
||||
|
||||
describe("modal height constraint regression", () => {
|
||||
it("desktop planning modal max-height accounts for overlay padding", async () => {
|
||||
const { container } = render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>
|
||||
);
|
||||
|
||||
const modal = container.querySelector(".planning-modal");
|
||||
expect(modal).toBeTruthy();
|
||||
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
|
||||
const blockMatch = css.match(
|
||||
/\.planning-modal\s*\{[^}]*max-height:\s*([^;]+);/,
|
||||
);
|
||||
expect(blockMatch).toBeTruthy();
|
||||
|
||||
const maxHeightValue = blockMatch![1].trim();
|
||||
expect(maxHeightValue).toContain("calc(");
|
||||
expect(maxHeightValue).toContain("100dvh");
|
||||
expect(maxHeightValue).toContain("--overlay-padding-top");
|
||||
});
|
||||
|
||||
it("uses planning-scoped disclosure overrides to remove inherited content indent", async () => {
|
||||
const { loadAllAppCssBaseOnly } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCssBaseOnly();
|
||||
|
||||
const blockMatch = css.match(
|
||||
/\.planning-advanced-disclosure\s+\.onboarding-disclosure-content\s*\{[^}]*\}/,
|
||||
);
|
||||
expect(blockMatch).toBeTruthy();
|
||||
expect(blockMatch![0]).toContain("padding-inline-start: 0;");
|
||||
expect(blockMatch![0]).toContain("justify-content: center;");
|
||||
});
|
||||
|
||||
it("keeps mobile question view top spacing compact", async () => {
|
||||
const { loadAllAppCss } = await import("../../test/cssFixture");
|
||||
const css = loadAllAppCss();
|
||||
const mobileBlocks = getMediaBlocks(css, "@media (max-width: 768px)");
|
||||
const mobileCss = mobileBlocks.join("\n");
|
||||
|
||||
expect(mobileCss).toContain(".planning-question-scroll");
|
||||
expect(mobileCss).toContain("padding-top: var(--space-sm);");
|
||||
expect(mobileCss).toContain("gap: var(--space-md);");
|
||||
});
|
||||
});
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
describe("PlanningModeModal initial surface", () => {
|
||||
it("keeps Sessions as the single escape hatch while the active interview is sequential", () => {
|
||||
const source = readFileSync(resolve(process.cwd(), "app/components/PlanningModeModal.tsx"), "utf8");
|
||||
expect(source).toContain('data-testid="planning-plan-review"');
|
||||
expect(source).toContain('data-testid="planning-refine-focus"');
|
||||
expect(source).toContain('data-testid="planning-create-retry"');
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -111,11 +111,11 @@ function installContextAwareAgent() {
|
||||
}
|
||||
|
||||
function createStore() {
|
||||
const createTask = vi.fn(async (input: { title: string; description: string }) => ({
|
||||
id: "FN-E2E-001",
|
||||
title: input.title,
|
||||
description: input.description,
|
||||
}));
|
||||
let createdTask: { id: string; title: string; description: string } | undefined;
|
||||
const createTask = vi.fn(async (input: { title: string; description: string }) => {
|
||||
createdTask = { id: "FN-E2E-001", title: input.title, description: input.description };
|
||||
return createdTask;
|
||||
});
|
||||
return {
|
||||
getSettings: vi.fn().mockResolvedValue({
|
||||
autoMerge: false,
|
||||
@@ -123,8 +123,11 @@ function createStore() {
|
||||
ntfyEnabled: false,
|
||||
}),
|
||||
getRootDir: vi.fn().mockReturnValue("/tmp/planning-e2e"),
|
||||
listTasks: vi.fn().mockResolvedValue([]),
|
||||
getTask: vi.fn(async () => { throw new Error("not found"); }),
|
||||
listTasks: vi.fn(async () => createdTask ? [createdTask] : []),
|
||||
getTask: vi.fn(async (id: string) => {
|
||||
if (createdTask?.id === id) return createdTask;
|
||||
throw new Error("not found");
|
||||
}),
|
||||
createTask,
|
||||
updateTask: vi.fn().mockResolvedValue(undefined),
|
||||
logEntry: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -219,11 +222,15 @@ describe("Planning Mode plan creation E2E", () => {
|
||||
expect(validate).toMatchObject({ status: 200, body: { validated: true } });
|
||||
|
||||
const created = await post(app, "/api/planning/create-task", { sessionId });
|
||||
expect(created).toMatchObject({ status: 201, body: { id: "FN-E2E-001", title: "Plan: Build secure account recovery" } });
|
||||
expect(created).toMatchObject({ status: 201, body: { task: { id: "FN-E2E-001", title: "Plan: Build secure account recovery" }, alreadyCreated: false } });
|
||||
expect(store.createTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
description: expect.stringContaining("## Key deliverables"),
|
||||
}));
|
||||
expect(await getSession(sessionId)).toBeUndefined();
|
||||
expect((await getSession(sessionId))?.createdTaskId).toBe("FN-E2E-001");
|
||||
|
||||
const retry = await post(app, "/api/planning/create-task", { sessionId });
|
||||
expect(retry).toMatchObject({ status: 200, body: { task: { id: "FN-E2E-001" }, alreadyCreated: true } });
|
||||
expect(store.createTask).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("keeps AI-authored options and Other in the input language", async () => {
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
} from "../planning.js";
|
||||
|
||||
const MOCK_TASK_STORE = {
|
||||
// FNXC:PlanningMode 2026-07-20-20:15: Agent-backed planning turns resolve the configured prompt lane before emitting the sequential question/plan transition.
|
||||
getSettings: vi.fn(async () => ({})),
|
||||
listTasks: vi.fn(async () => []),
|
||||
getTask: vi.fn(async () => { throw new Error("not found"); }),
|
||||
} as unknown as TaskStore;
|
||||
@@ -191,10 +193,11 @@ describe("reactive Planning Mode question contract", () => {
|
||||
[fallbackQuestion.id]: "other",
|
||||
_other: "Ask about audit-log security before anything else.",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(next).toEqual(expect.objectContaining({ type: "question", data: expect.objectContaining({ id: "rollout" }) }));
|
||||
// FNXC:PlanningMode 2026-07-20-20:15: Answers update the plan only; the next
|
||||
// question is user-triggered by refine so a focus can steer it.
|
||||
expect(next).toEqual(expect.objectContaining({ type: "complete" }));
|
||||
expect((await getSession(sessionId))?.currentQuestion).toBeUndefined();
|
||||
expect(prompts.at(-1)).toContain("Ask about audit-log security before anything else.");
|
||||
expect(prompts.at(-1)).toContain("exactly one new, high-impact question");
|
||||
expect(prompts.at(-1)).toContain("only the user can validate it");
|
||||
expect(events.filter((type) => type === "summary")).toHaveLength(2);
|
||||
|
||||
await validateSession(sessionId);
|
||||
@@ -217,8 +220,8 @@ describe("reactive Planning Mode question contract", () => {
|
||||
scope: "other",
|
||||
_other: "Ask me questions about audit logging security instead.",
|
||||
}, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(firstNext.type).toBe("question");
|
||||
expect(firstNext.data.id).not.toBe("scope");
|
||||
expect(firstNext.type).toBe("complete");
|
||||
expect((await getSession(created.sessionId))?.currentQuestion).toBeUndefined();
|
||||
expect(prompts[1]).toContain("Ask me questions about audit logging security instead.");
|
||||
|
||||
const afterCompletion = await getSession(created.sessionId);
|
||||
@@ -228,11 +231,7 @@ describe("reactive Planning Mode question contract", () => {
|
||||
title: "Secure account recovery delivery",
|
||||
keyDeliverables: ["Implement recovery workflow", "Verify audit coverage"],
|
||||
});
|
||||
expect(afterCompletion?.currentQuestion).toBeDefined();
|
||||
|
||||
const secondQuestion = afterCompletion!.currentQuestion!;
|
||||
const secondNext = await submitResponse(created.sessionId, { [secondQuestion.id]: "option-1" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
expect(secondNext).toEqual(expect.objectContaining({ type: "question" }));
|
||||
expect(afterCompletion?.currentQuestion).toBeUndefined();
|
||||
expect((await getSession(created.sessionId))?.summary).toBeDefined();
|
||||
expect((await getSession(created.sessionId))?.validated).toBe(false);
|
||||
|
||||
@@ -374,30 +373,19 @@ describe("reactive Planning Mode question contract", () => {
|
||||
expect(session?.validated).toBe(false);
|
||||
});
|
||||
|
||||
it("replays an edited historical answer while retaining later answers and appending a fresh question", async () => {
|
||||
installScriptedAgent([
|
||||
payload(FIRST_QUESTION),
|
||||
payload(SECOND_QUESTION),
|
||||
payload({ ...SECOND_QUESTION, id: "verification", question: "What verification is required?" }),
|
||||
payload({ ...SECOND_QUESTION, id: "ignored-replay" }),
|
||||
payload({ ...SECOND_QUESTION, id: "ignored-replay-after-edit" }),
|
||||
payload({ ...SECOND_QUESTION, id: "fresh-after-edit", question: "What risk remains after the edit?" }),
|
||||
]);
|
||||
it("replays an edited historical answer into plan review without automatically asking a question", async () => {
|
||||
installScriptedAgent([payload(FIRST_QUESTION), completePayload(), completePayload(), completePayload(), completePayload()]);
|
||||
const created = await createSession("127.0.0.2", "Improve audit trails", MOCK_TASK_STORE, "/tmp/project");
|
||||
await submitResponse(created.sessionId, { scope: "secure" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
const second = (await getSession(created.sessionId))!.currentQuestion!;
|
||||
await submitResponse(created.sessionId, { [second.id]: "gradual" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
await rewindSession(created.sessionId, "scope", "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
await submitResponse(created.sessionId, { scope: "fast" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
const revised = await submitResponse(created.sessionId, { scope: "fast" }, "/tmp/project", undefined, MOCK_TASK_STORE);
|
||||
|
||||
const edited = await getSession(created.sessionId);
|
||||
expect(edited?.history).toHaveLength(2);
|
||||
expect(revised.type).toBe("complete");
|
||||
expect(edited?.history).toHaveLength(1);
|
||||
expect(edited?.history[0]?.response).toEqual({ scope: "fast" });
|
||||
expect(edited?.history[1]?.response).toEqual({ [second.id]: "gradual" });
|
||||
expect(edited?.currentQuestion?.id).toBe("fresh-after-edit");
|
||||
expect(edited?.summary?.description).toContain("Fast delivery");
|
||||
expect(edited?.summary?.description.match(/Gradual rollout/g)).toHaveLength(1);
|
||||
expect(edited?.summary?.keyDeliverables).not.toContain(edited?.currentQuestion?.question);
|
||||
expect(edited?.currentQuestion).toBeUndefined();
|
||||
expect(edited?.summary).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1591,7 +1591,7 @@ describe("Planning Mode Routes", () => {
|
||||
});
|
||||
|
||||
describe("POST /planning/respond", () => {
|
||||
it("processes response and returns next question", async () => {
|
||||
it("updates the plan after an answer", async () => {
|
||||
// First create a session
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -1613,65 +1613,18 @@ describe("Planning Mode Routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("question");
|
||||
expect(res.body.type).toBe("complete");
|
||||
expect(res.body.data).toBeDefined();
|
||||
});
|
||||
|
||||
it("returns checkpoint before summary, then completes only after explicit proceed", async () => {
|
||||
// Create a session
|
||||
const startRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/start",
|
||||
JSON.stringify({ initialPlan: "Build a user auth system" }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
it("requires an explicit refine request before asking another question", async () => {
|
||||
const startRes = await REQUEST(buildApp(), "POST", "/api/planning/start", JSON.stringify({ initialPlan: "Build a user auth system" }), { "Content-Type": "application/json" });
|
||||
const sessionId = startRes.body.sessionId;
|
||||
const answer = await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { scope: "medium" } }), { "Content-Type": "application/json" });
|
||||
expect(answer.body).toMatchObject({ type: "complete" });
|
||||
|
||||
// Submit 3 responses to complete the session
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { scope: "medium" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { requirements: "Must have login" } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
const checkpointRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({ sessionId, responses: { confirm: true } }),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(checkpointRes.status).toBe(200);
|
||||
expect(checkpointRes.body.type).toBe("question");
|
||||
expect(checkpointRes.body.data.id).toEqual(expect.any(String));
|
||||
expect(checkpointRes.body.data.question).toEqual(expect.any(String));
|
||||
|
||||
const finalRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
||||
}),
|
||||
{ "Content-Type": "application/json" }
|
||||
);
|
||||
|
||||
expect(finalRes.status).toBe(200);
|
||||
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
|
||||
expect(planningModule.getSummary(sessionId)).toBeDefined();
|
||||
const refine = await REQUEST(buildApp(), "POST", "/api/planning/respond", JSON.stringify({ sessionId, responses: { refine: true, focus: "security" } }), { "Content-Type": "application/json" });
|
||||
expect(refine.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
|
||||
});
|
||||
|
||||
it("FN-6977 normalizes omitted summary arrays from live AI completion", async () => {
|
||||
@@ -1725,11 +1678,8 @@ describe("Planning Mode Routes", () => {
|
||||
|
||||
expect(checkpointRes.status).toBe(200);
|
||||
expect(checkpointRes.body).toMatchObject({
|
||||
type: "question",
|
||||
data: {
|
||||
id: expect.any(String),
|
||||
question: expect.any(String),
|
||||
},
|
||||
type: "complete",
|
||||
data: { title: "Malformed AI summary" },
|
||||
});
|
||||
expect(planningModule.getSummary(sessionId)).toMatchObject({
|
||||
suggestedDependencies: ["FN-100"],
|
||||
@@ -1738,17 +1688,6 @@ describe("Planning Mode Routes", () => {
|
||||
]),
|
||||
});
|
||||
|
||||
const finalRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
|
||||
expect(planningModule.getSummary(sessionId)).toMatchObject({
|
||||
suggestedDependencies: ["FN-100"],
|
||||
keyDeliverables: expect.arrayContaining([
|
||||
@@ -1817,7 +1756,7 @@ describe("Planning Mode Routes", () => {
|
||||
JSON.stringify({ sessionId, responses: { "q-one": "Make it responsive and tested" } }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(checkpointRes.body.data.question).toEqual(expect.any(String));
|
||||
expect(checkpointRes.body.type).toBe("complete");
|
||||
|
||||
const deepeningRes = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -1825,10 +1764,7 @@ describe("Planning Mode Routes", () => {
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
responses: {
|
||||
[PLANNING_DEEPEN_CHECKPOINT_ID]: ["theme-ux"],
|
||||
_other: "Explore rollout risk",
|
||||
},
|
||||
responses: { refine: true, focus: "Explore rollout risk" },
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
@@ -1845,22 +1781,7 @@ describe("Planning Mode Routes", () => {
|
||||
JSON.stringify({ sessionId, responses: { "q-deeper": "Keyboard and touch interactions" } }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(secondCheckpointRes.body).toMatchObject({
|
||||
type: "question",
|
||||
data: { id: expect.any(String), question: expect.any(String) },
|
||||
});
|
||||
|
||||
const finalRes = await REQUEST(
|
||||
buildApp(),
|
||||
"POST",
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(finalRes.body).toMatchObject({ type: "question", data: { id: expect.any(String) } });
|
||||
expect(secondCheckpointRes.body.type).toBe("complete");
|
||||
});
|
||||
|
||||
it("prefers AI-authored deepeningThemes over generic themes on both completion paths", async () => {
|
||||
@@ -1914,13 +1835,7 @@ describe("Planning Mode Routes", () => {
|
||||
JSON.stringify({ sessionId, responses: { "q-offline-context": "Conflicts and recovery" } }),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
expect(interviewRes.body.data.question).toEqual(expect.any(String));
|
||||
expect(interviewRes.body.data.options?.[0]?.id).toEqual(expect.any(String));
|
||||
expect(interviewRes.body.data.options?.map((option: { label: string }) => option.label)).toEqual([
|
||||
"Option 1",
|
||||
"Option 2",
|
||||
"Other (write your own)",
|
||||
]);
|
||||
expect(interviewRes.body.type).toBe("complete");
|
||||
|
||||
const deepeningRes = await REQUEST(
|
||||
buildApp(),
|
||||
@@ -1928,7 +1843,7 @@ describe("Planning Mode Routes", () => {
|
||||
"/api/planning/respond",
|
||||
JSON.stringify({
|
||||
sessionId,
|
||||
responses: { [PLANNING_DEEPEN_CHECKPOINT_ID]: [PLANNING_DEEPEN_PROCEED_OPTION_ID] },
|
||||
responses: { refine: true },
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
@@ -3989,7 +3904,7 @@ describe("Saturated-slot regression: utility AI routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("question");
|
||||
expect(res.body.type).toBe("complete");
|
||||
});
|
||||
|
||||
/*
|
||||
@@ -4026,7 +3941,7 @@ describe("Saturated-slot regression: utility AI routes", () => {
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.type).toBe("question");
|
||||
expect(res.body.type).toBe("complete");
|
||||
expect(mockAiSessionStore.acquireLock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,10 @@ import { THINKING_LEVELS, type AsyncDataLayer, type ThinkingLevel } from "@fusio
|
||||
import {
|
||||
upsertAiSession,
|
||||
getAiSession,
|
||||
claimPlanningSessionTaskCreation,
|
||||
finalizePlanningSessionTaskCreation,
|
||||
reconcilePlanningSessionTaskCreation,
|
||||
releasePlanningSessionTaskCreation,
|
||||
listActiveAiSessions,
|
||||
listAllAiSessions,
|
||||
listRecoverableAiSessions,
|
||||
@@ -147,6 +151,35 @@ export class AiSessionStore extends EventEmitter<AiSessionStoreEvents> {
|
||||
return;
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-20:15:
|
||||
Planning creation claims must use one conditional database update, not a read then an
|
||||
upsert, so competing dashboard processes cannot both become the creator.
|
||||
*/
|
||||
async claimPlanningTaskCreation(sessionId: string, ownerToken: string, startedAt: string): Promise<AiSessionRow | null> {
|
||||
const row = await claimPlanningSessionTaskCreation(this.dbAsync, sessionId, ownerToken, startedAt) as AiSessionRow | null;
|
||||
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
return row;
|
||||
}
|
||||
|
||||
async finalizePlanningTaskCreation(sessionId: string, ownerToken: string, taskId: string): Promise<AiSessionRow | null> {
|
||||
const row = await finalizePlanningSessionTaskCreation(this.dbAsync, sessionId, ownerToken, taskId) as AiSessionRow | null;
|
||||
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
return row;
|
||||
}
|
||||
|
||||
async reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise<AiSessionRow | null> {
|
||||
const row = await reconcilePlanningSessionTaskCreation(this.dbAsync, sessionId, taskId) as AiSessionRow | null;
|
||||
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
return row;
|
||||
}
|
||||
|
||||
async releasePlanningTaskCreation(sessionId: string, ownerToken: string): Promise<AiSessionRow | null> {
|
||||
const row = await releasePlanningSessionTaskCreation(this.dbAsync, sessionId, ownerToken) as AiSessionRow | null;
|
||||
if (row) this.emit("ai_session:updated", toSummary(row, row.updatedAt));
|
||||
return row;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update only the thinkingOutput field, debounced to reduce write frequency.
|
||||
* Flushes immediately if `flush` is true (e.g. on status transition).
|
||||
|
||||
@@ -240,11 +240,11 @@ export const PLANNING_SYSTEM_PROMPT = `## Planning Mode interaction adapter
|
||||
|
||||
First analyze the codebase and active board with the available readonly tools, fn_task_list, and fn_task_show. Treat the workflow planning template above as the quality bar and PROMPT.md structure for the evolving plan, but do not write PROMPT.md or use write tools during this interview.
|
||||
|
||||
Ask exactly one next, high-impact question on every turn. Use every prior answer as context, avoid repeated questions, and never decide that the interview is complete or emit a terminal/complete response. Only the user can validate the plan.
|
||||
Ask exactly one next, high-impact question only when the user explicitly requests a refine turn. After an answer, update the running plan and do not ask a question. Never decide that the interview is complete or emit a terminal/complete response. Only the user can validate the plan.
|
||||
|
||||
Respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}.
|
||||
For a refine turn respond only with JSON: {"type":"question","data":{"id":"unique-id","type":"single_select|multi_select","question":"...","description":"...","options":[{"id":"option-a","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"option-b","label":"...","description":"...","pros":["..."],"cons":["..."]},{"id":"other","label":"...","isOther":true}],"runningPlan":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}}. For an answer-update turn respond only with {"type":"complete","data":{"title":"...","description":"...","suggestedSize":"S|M|L","priority":"normal","suggestedDependencies":[],"keyDeliverables":["concrete work item"]}}.
|
||||
|
||||
Every turn must include runningPlan: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
Every turn must include the running-plan fields: only title, description, suggestedSize, optional priority, suggestedDependencies, and concrete keyDeliverables informed by the idea and answers so far. Never use interview question text as a deliverable. Do not put PROMPT.md sections (Mission, Before → After, Steps, File Scope, Review Level, Completion Criteria, or Do NOT) in runningPlan or free text: triage writes PROMPT.md only after Validate. Validate serializes this lean plan as plan.md without priority; priority remains a task field. Every question must provide at least two alternatives, each with non-empty pros and cons, plus exactly one Other/write-your-own option. Write every label, option, and Other label in the language of the user's original input. Incorporate free-text Other answers verbatim as steering context for the following question.`;
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-14:30:
|
||||
@@ -311,6 +311,10 @@ export interface DraftInputPayload {
|
||||
summarizedFor?: string;
|
||||
validated?: boolean;
|
||||
workflowId?: string;
|
||||
createdTaskId?: string;
|
||||
createClaimStatus?: "none" | "creating" | "created";
|
||||
claimOwnerToken?: string;
|
||||
claimStartedAt?: string;
|
||||
}
|
||||
|
||||
/** Session TTL in milliseconds (7 days) */
|
||||
@@ -389,6 +393,13 @@ interface Session {
|
||||
summary?: PlanningSummary;
|
||||
/** User-controlled finalization state. */
|
||||
validated: boolean;
|
||||
/** Durable create-task linkage; proposalClaimId remains the crash-recovery authority. */
|
||||
createdTaskId?: string;
|
||||
createClaimStatus?: "none" | "creating" | "created";
|
||||
claimOwnerToken?: string;
|
||||
claimStartedAt?: string;
|
||||
/** Whether the current generation must end at plan review rather than a question. */
|
||||
generationPurpose?: "plan_update" | "question";
|
||||
/** Last terminal error for retry UX */
|
||||
error?: string;
|
||||
/** AI agent session for real-time interaction */
|
||||
@@ -589,6 +600,10 @@ function persistSession(session: Session, status: "generating" | "awaiting_input
|
||||
...(session.draftSummarizedFor ? { summarizedFor: session.draftSummarizedFor } : {}),
|
||||
...(session.workflowId ? { workflowId: session.workflowId } : {}),
|
||||
validated: session.validated,
|
||||
...(session.createdTaskId ? { createdTaskId: session.createdTaskId } : {}),
|
||||
...(session.createClaimStatus ? { createClaimStatus: session.createClaimStatus } : {}),
|
||||
...(session.claimOwnerToken ? { claimOwnerToken: session.claimOwnerToken } : {}),
|
||||
...(session.claimStartedAt ? { claimStartedAt: session.claimStartedAt } : {}),
|
||||
...(typeof session.clarificationEnabled === "boolean"
|
||||
? { clarificationEnabled: session.clarificationEnabled }
|
||||
: {}),
|
||||
@@ -735,6 +750,10 @@ function buildSessionFromRow(row: AiSessionRow): Session {
|
||||
lastNotifiedQuestionKey: currentQuestion ? `${row.id}:${currentQuestion.id}` : undefined,
|
||||
summary: persistedSummary ?? buildRunningSummary(payload.initialPlan ?? row.title, history),
|
||||
validated: payload.validated === true,
|
||||
createdTaskId: typeof payload.createdTaskId === "string" ? payload.createdTaskId : undefined,
|
||||
createClaimStatus: payload.createClaimStatus,
|
||||
claimOwnerToken: typeof payload.claimOwnerToken === "string" ? payload.claimOwnerToken : undefined,
|
||||
claimStartedAt: typeof payload.claimStartedAt === "string" ? payload.claimStartedAt : undefined,
|
||||
thinkingOutput: row.thinkingOutput,
|
||||
lastGeneratedThinking: row.thinkingOutput || "",
|
||||
error: row.error ?? undefined,
|
||||
@@ -2418,18 +2437,25 @@ async function continueAgentConversation(session: Session, message: string): Pro
|
||||
return;
|
||||
}
|
||||
|
||||
// A generic engine completion is never terminal in Planning Mode. Its data remains
|
||||
// eligible as a model-authored running plan while the fallback question keeps interviewing.
|
||||
session.currentQuestion = coerceQuestionResponse(parsed, session);
|
||||
session.summary = mergeRunningSummary(session, parsed);
|
||||
session.error = undefined;
|
||||
session.lastGeneratedThinking = session.thinkingOutput;
|
||||
session.updatedAt = new Date();
|
||||
// Persist after deriving the plan: reloads must see the running summary on every turn.
|
||||
const planUpdate = session.generationPurpose === "plan_update";
|
||||
session.generationPurpose = undefined;
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-15:45:
|
||||
A model can accidentally return question-shaped JSON during an answer update. FN-8442
|
||||
deliberately strips that question instead of exposing it: the next question is exclusively
|
||||
triggered by the plan-review Refine action, optionally with operator focus text.
|
||||
*/
|
||||
session.currentQuestion = planUpdate ? undefined : coerceQuestionResponse(parsed, session);
|
||||
persistSession(session, "awaiting_input");
|
||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "summary", data: session.summary });
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
if (session.currentQuestion) {
|
||||
void maybeNotifyPlanningAwaitingInput(session, session.currentQuestion, true);
|
||||
planningStreamManager.broadcast(session.id, { type: "question", data: session.currentQuestion });
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && err.name === "AbortError") {
|
||||
@@ -2693,12 +2719,13 @@ function isRefineRequest(responses: Record<string, unknown>): boolean {
|
||||
return responses.refine === true;
|
||||
}
|
||||
|
||||
function formatRefineRequestForAgent(summary: PlanningSummary): string {
|
||||
function formatRefineRequestForAgent(summary: PlanningSummary, focus?: string): string {
|
||||
return [
|
||||
"The user clicked Refine Further on the planning summary.",
|
||||
"Continue the planning interview from the existing context.",
|
||||
"Ask exactly one focused, high-impact follow-up question with alternatives and pros/cons.",
|
||||
"Do not return a completion response: only the user can validate a plan.",
|
||||
...(focus ? ["The operator wants this next question to focus on:", focus] : []),
|
||||
"Current summary:",
|
||||
JSON.stringify(summary),
|
||||
].join("\n\n");
|
||||
@@ -2783,7 +2810,9 @@ export async function submitResponse(
|
||||
persistSession(session, "generating");
|
||||
|
||||
await ensureSessionAgent(session, rootDir, session.history, promptOverrides, store);
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary);
|
||||
session.generationPurpose = "question";
|
||||
const focus = typeof responses.focus === "string" ? responses.focus.trim() : undefined;
|
||||
const refineMessage = formatRefineRequestForAgent(session.summary, focus);
|
||||
await continueAgentConversation(session, refineMessage);
|
||||
} else {
|
||||
const currentQuestion = captureOtherCustomText(session.currentQuestion, responses);
|
||||
@@ -2814,8 +2843,14 @@ export async function submitResponse(
|
||||
}
|
||||
answeredQuestion = currentQuestion;
|
||||
|
||||
// Answered questions always lead to another question; no answer can validate or complete.
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-15:45:
|
||||
FN-8442 makes answering a plan-update turn. Clear the active question before generation
|
||||
and record its purpose so only an explicit `{ refine: true, focus? }` may surface another
|
||||
question; this keeps the plan-review decision point under operator control.
|
||||
*/
|
||||
session.currentQuestion = undefined;
|
||||
session.generationPurpose = "plan_update";
|
||||
persistSession(session, "generating");
|
||||
if (!session.agent) {
|
||||
// An edited older answer must be replayed in its original position with every
|
||||
@@ -2829,8 +2864,8 @@ export async function submitResponse(
|
||||
);
|
||||
}
|
||||
const message = isEditingPriorAnswer
|
||||
? "An earlier answer was edited. Use the complete preserved interview context above, re-derive the running plan, and ask exactly one new high-impact next question."
|
||||
: formatResponseForAgent(currentQuestion, responses);
|
||||
? "An earlier answer was edited. Use the complete preserved interview context above and return only the updated plan JSON; do not ask another question."
|
||||
: `${formatResponseForAgent(currentQuestion, responses)}\n\nUpdate the running plan only. Do not ask a next question; the operator may explicitly refine after reviewing the plan.`;
|
||||
await continueAgentConversation(session, message);
|
||||
}
|
||||
|
||||
@@ -2851,7 +2886,10 @@ export async function submitResponse(
|
||||
return { type: "question", data: answeredQuestion };
|
||||
}
|
||||
|
||||
// Should not reach here, but handle gracefully
|
||||
if (session.summary) {
|
||||
return { type: "complete", data: session.summary };
|
||||
}
|
||||
|
||||
throw new InvalidSessionStateError("AI agent did not return a question or summary");
|
||||
}
|
||||
|
||||
@@ -3234,6 +3272,78 @@ export function getSummary(sessionId: string): PlanningSummary | undefined {
|
||||
return sessions.get(sessionId)?.summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist planning create-task claim/linkage state. The task row's stable proposalClaimId is
|
||||
* authoritative after a crash; these fields make restore and UI handoff deterministic.
|
||||
*/
|
||||
export async function updatePlanningCreateClaim(
|
||||
sessionId: string,
|
||||
patch: Pick<Session, "createClaimStatus" | "createdTaskId" | "claimOwnerToken" | "claimStartedAt">,
|
||||
): Promise<void> {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session) throw new SessionNotFoundError(`Planning session ${sessionId} not found or expired`);
|
||||
Object.assign(session, patch);
|
||||
session.updatedAt = new Date();
|
||||
await persistSession(session, session.validated ? "complete" : "awaiting_input");
|
||||
}
|
||||
|
||||
function restoreClaimSession(row: import("./ai-session-store.js").AiSessionRow): Session {
|
||||
const restored = buildSessionFromRow(row);
|
||||
sessions.set(restored.id, restored);
|
||||
return restored;
|
||||
}
|
||||
|
||||
/** Read durable claim state rather than trusting a process-local session cache. */
|
||||
export async function getDurablePlanningSession(sessionId: string): Promise<Session | undefined> {
|
||||
if (!_aiSessionStore) return getSession(sessionId);
|
||||
const row = await _aiSessionStore.get(sessionId);
|
||||
return row?.type === "planning" ? restoreClaimSession(row) : undefined;
|
||||
}
|
||||
|
||||
/** Atomically claim a validated planning session for its one task creation. */
|
||||
export async function claimPlanningTaskCreation(sessionId: string, ownerToken: string, startedAt: string): Promise<Session | undefined> {
|
||||
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { claimPlanningTaskCreation?: unknown }).claimPlanningTaskCreation !== "function") {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session || session.createClaimStatus === "creating" || session.createClaimStatus === "created") return undefined;
|
||||
Object.assign(session, { createClaimStatus: "creating", claimOwnerToken: ownerToken, claimStartedAt: startedAt });
|
||||
return session;
|
||||
}
|
||||
const row = await _aiSessionStore.claimPlanningTaskCreation(sessionId, ownerToken, startedAt);
|
||||
return row ? restoreClaimSession(row) : undefined;
|
||||
}
|
||||
|
||||
export async function finalizePlanningTaskCreation(sessionId: string, ownerToken: string, taskId: string): Promise<Session | undefined> {
|
||||
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { finalizePlanningTaskCreation?: unknown }).finalizePlanningTaskCreation !== "function") {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session || session.claimOwnerToken !== ownerToken) return undefined;
|
||||
Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined });
|
||||
return session;
|
||||
}
|
||||
const row = await _aiSessionStore.finalizePlanningTaskCreation(sessionId, ownerToken, taskId);
|
||||
return row ? restoreClaimSession(row) : undefined;
|
||||
}
|
||||
|
||||
export async function reconcilePlanningTaskCreation(sessionId: string, taskId: string): Promise<Session | undefined> {
|
||||
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { reconcilePlanningTaskCreation?: unknown }).reconcilePlanningTaskCreation !== "function") {
|
||||
const session = await getSession(sessionId);
|
||||
if (session) Object.assign(session, { createClaimStatus: "created", createdTaskId: taskId, claimOwnerToken: undefined, claimStartedAt: undefined });
|
||||
return session;
|
||||
}
|
||||
const row = await _aiSessionStore.reconcilePlanningTaskCreation(sessionId, taskId);
|
||||
return row ? restoreClaimSession(row) : undefined;
|
||||
}
|
||||
|
||||
export async function releasePlanningTaskCreation(sessionId: string, ownerToken: string): Promise<Session | undefined> {
|
||||
if (!_aiSessionStore || typeof (_aiSessionStore as unknown as { releasePlanningTaskCreation?: unknown }).releasePlanningTaskCreation !== "function") {
|
||||
const session = await getSession(sessionId);
|
||||
if (!session || session.claimOwnerToken !== ownerToken) return undefined;
|
||||
Object.assign(session, { createClaimStatus: "none", claimOwnerToken: undefined, claimStartedAt: undefined });
|
||||
return session;
|
||||
}
|
||||
const row = await _aiSessionStore.releasePlanningTaskCreation(sessionId, ownerToken);
|
||||
return row ? restoreClaimSession(row) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate subtasks from a completed planning summary.
|
||||
* Uses the planning session's summary to create a SubtaskItem[] for multi-task creation.
|
||||
|
||||
@@ -18,9 +18,33 @@ import type { AiSessionStore } from "../ai-session-store.js";
|
||||
import type { ApiRoutesContext } from "./types.js";
|
||||
import { resolveBranchAssignmentContext, resolveBranchSelection, resolveEntryPointBranchAssignment } from "./branch-selection.js";
|
||||
import { requireAsyncLayer } from "../require-async-layer.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
type SkillPluginRunner = Parameters<typeof import("@fusion/engine").buildSessionSkillContextSync>[3];
|
||||
|
||||
const planningCreateLocks = new Map<string, Promise<void>>();
|
||||
|
||||
/**
|
||||
* FNXC:PlanningMode 2026-07-20-15:45:
|
||||
* The stable proposalClaimId is the cross-process authority, while this short-lived lock avoids
|
||||
* duplicate local createTask calls before a same-process retry can observe its finalized linkage.
|
||||
* A process death releases this memory only; retry reconciliation still queries the task mapping.
|
||||
*/
|
||||
async function acquirePlanningCreateLock(sessionId: string): Promise<() => void> {
|
||||
const previous = planningCreateLocks.get(sessionId);
|
||||
let releaseGate!: () => void;
|
||||
const gate = new Promise<void>((resolve) => { releaseGate = resolve; });
|
||||
const tail = previous ? previous.then(() => gate) : gate;
|
||||
planningCreateLocks.set(sessionId, tail);
|
||||
await previous;
|
||||
return () => {
|
||||
releaseGate();
|
||||
void tail.finally(() => {
|
||||
if (planningCreateLocks.get(sessionId) === tail) planningCreateLocks.delete(sessionId);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
interface PlanningSubtaskRouteDeps {
|
||||
store: TaskStore;
|
||||
aiSessionStore?: AiSessionStore;
|
||||
@@ -1103,6 +1127,9 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
* Returns: Created Task
|
||||
*/
|
||||
router.post("/planning/create-task", async (req, res) => {
|
||||
let releaseCreateLock: (() => void) | undefined;
|
||||
let claimedOwnerToken: string | undefined;
|
||||
let claimedSessionId: string | undefined;
|
||||
try {
|
||||
const { sessionId, summary: summaryInput, branch, baseBranch, branchSelection, workflowId } = req.body as {
|
||||
sessionId?: unknown;
|
||||
@@ -1124,9 +1151,18 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
const summaryOverride = parsePlanningSummaryOverride(summaryInput);
|
||||
|
||||
const { store: scopedStore } = await getProjectContext(req);
|
||||
const { getSession, getSummary, releaseSession } = await import("../planning.js");
|
||||
const {
|
||||
getSession,
|
||||
getSummary,
|
||||
updatePlanningCreateClaim,
|
||||
getDurablePlanningSession,
|
||||
claimPlanningTaskCreation,
|
||||
finalizePlanningTaskCreation,
|
||||
reconcilePlanningTaskCreation,
|
||||
releasePlanningTaskCreation,
|
||||
} = await import("../planning.js");
|
||||
|
||||
const session = await getSession(sessionId);
|
||||
let session = await getSession(sessionId);
|
||||
if (session && !session.validated) throw badRequest("Planning session must be validated before creating tasks");
|
||||
let summary = summaryOverride ?? getSummary(sessionId);
|
||||
let initialPlan = session?.initialPlan;
|
||||
@@ -1185,6 +1221,70 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
throw badRequest("Planning session is not complete");
|
||||
}
|
||||
|
||||
releaseCreateLock = await acquirePlanningCreateLock(sessionId);
|
||||
// Re-read after the local single-flight queue: an earlier caller may have finalized while we waited.
|
||||
session = await getSession(sessionId);
|
||||
|
||||
/*
|
||||
FNXC:PlanningMode 2026-07-20-15:45:
|
||||
FN-8442 derives the never-rotated key `planning-session:${sessionId}` at task creation.
|
||||
The task table's partial unique proposalClaimId index, not this process's claim state, is
|
||||
the multi-process and crash-after-insert authority. A session linkage is a durable cache
|
||||
reconciled from that key; a missing linked task fails closed rather than silently forking.
|
||||
*/
|
||||
const proposalClaimId = `planning-session:${sessionId}`;
|
||||
const findCreatedTask = async () =>
|
||||
(await scopedStore.listTasks({ includeArchived: true })).find((candidate) => candidate.proposalClaimId === proposalClaimId);
|
||||
const returnLinkedTask = async (candidate = session) => {
|
||||
if (!candidate?.createdTaskId) return false;
|
||||
const linkedTask = await scopedStore.getTask(candidate.createdTaskId).catch(() => null);
|
||||
if (!linkedTask) throw conflict("PLANNING_CREATED_TASK_MISSING");
|
||||
res.status(200).json({ task: linkedTask, alreadyCreated: true });
|
||||
return true;
|
||||
};
|
||||
|
||||
// A task row is the crash-window authority. Reconcile it before trying to claim.
|
||||
const existingTask = await findCreatedTask();
|
||||
if (existingTask) {
|
||||
await reconcilePlanningTaskCreation(sessionId, existingTask.id);
|
||||
res.status(200).json({ task: existingTask, alreadyCreated: true });
|
||||
return;
|
||||
}
|
||||
session = await getDurablePlanningSession(sessionId) ?? session;
|
||||
if (await returnLinkedTask(session)) return;
|
||||
|
||||
const claimOwnerToken = randomUUID();
|
||||
claimedOwnerToken = claimOwnerToken;
|
||||
claimedSessionId = sessionId;
|
||||
const claimStartedAt = new Date().toISOString();
|
||||
const hasDurableClaimStore = typeof (aiSessionStore as unknown as { claimPlanningTaskCreation?: unknown } | undefined)?.claimPlanningTaskCreation === "function";
|
||||
let claimed = hasDurableClaimStore
|
||||
? await claimPlanningTaskCreation(sessionId, claimOwnerToken, claimStartedAt)
|
||||
: session
|
||||
? session.createClaimStatus !== "creating" && session.createClaimStatus !== "created"
|
||||
? (await updatePlanningCreateClaim(sessionId, { createClaimStatus: "creating", claimOwnerToken, claimStartedAt, createdTaskId: undefined }), session)
|
||||
: undefined
|
||||
// Legacy test/session adapters can provide only the route's persisted row. They do
|
||||
// not model a durable claim API, so retain the pre-CAS behavior for that adapter.
|
||||
: ({} as NonNullable<typeof session>);
|
||||
if (!claimed) {
|
||||
// The failed conditional update means another process owns (or completed) this claim.
|
||||
session = await getDurablePlanningSession(sessionId) ?? session;
|
||||
const recoveredTask = await findCreatedTask();
|
||||
if (recoveredTask) {
|
||||
await reconcilePlanningTaskCreation(sessionId, recoveredTask.id);
|
||||
res.status(200).json({ task: recoveredTask, alreadyCreated: true });
|
||||
return;
|
||||
}
|
||||
if (await returnLinkedTask(session)) return;
|
||||
const startedAt = session?.claimStartedAt ? Date.parse(session.claimStartedAt) : Number.NaN;
|
||||
const leaseExpired = session?.createClaimStatus === "creating" && Number.isFinite(startedAt) && Date.now() - startedAt >= 30_000;
|
||||
if (!leaseExpired || !session?.claimOwnerToken) throw conflict("Planning task creation is already in progress");
|
||||
await releasePlanningTaskCreation(sessionId, session.claimOwnerToken);
|
||||
claimed = await claimPlanningTaskCreation(sessionId, claimOwnerToken, new Date().toISOString());
|
||||
if (!claimed) throw conflict("Planning task creation is already in progress");
|
||||
}
|
||||
|
||||
const { branch: resolvedBranch, baseBranch: resolvedBaseBranch } =
|
||||
resolveBranchSelection(branchSelection, branch, baseBranch);
|
||||
|
||||
@@ -1220,6 +1320,7 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
Planning Mode creates tasks from the board context, so an active workflow lane must be materialized at create time when the client supplies it.
|
||||
*/
|
||||
...(workflowId !== undefined ? { workflowId: workflowId as string | null } : {}),
|
||||
proposalClaimId,
|
||||
});
|
||||
|
||||
// Update task with suggested size if provided.
|
||||
@@ -1251,18 +1352,25 @@ export function registerPlanningSubtaskRoutes(ctx: ApiRoutesContext, deps: Plann
|
||||
{ taskId: task.id, sessionId },
|
||||
);
|
||||
|
||||
// Release any live in-memory planning runtime for this session, but
|
||||
// keep the persisted completed row so planning history can still list
|
||||
// and restore the summary after single-task creation.
|
||||
await runPlanningCreateSideEffect(
|
||||
"Planning create-task session release failed",
|
||||
() => releaseSession(sessionId),
|
||||
{ taskId: task.id, sessionId },
|
||||
);
|
||||
// Write the linkage before responding. If this write is interrupted, the next retry
|
||||
// reconciles the unique proposalClaimId task mapping above and never inserts another task.
|
||||
if (hasDurableClaimStore) {
|
||||
await finalizePlanningTaskCreation(sessionId, claimOwnerToken, task.id);
|
||||
} else if (session) {
|
||||
await updatePlanningCreateClaim(sessionId, { createClaimStatus: "created", createdTaskId: task.id, claimOwnerToken: undefined, claimStartedAt: undefined });
|
||||
}
|
||||
|
||||
res.status(201).json(task);
|
||||
res.status(201).json({ task, alreadyCreated: false });
|
||||
} catch (err: unknown) {
|
||||
// A failed insert may release only this request's owner token. A successful insert whose
|
||||
// finalization failed remains recoverable through proposalClaimId on the next request.
|
||||
if (claimedOwnerToken) {
|
||||
const { releasePlanningTaskCreation } = await import("../planning.js");
|
||||
await releasePlanningTaskCreation(claimedSessionId as string, claimedOwnerToken).catch(() => undefined);
|
||||
}
|
||||
rethrowPlanningWorkflowCreateError(err, "Failed to create task", rethrowAsApiError);
|
||||
} finally {
|
||||
releaseCreateLock?.();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user