FN-9117: Preserve Planning Mode answers across session hydration
Keep visible Planning Mode controls bound to the current turn during asynchronous refreshes. - preserve active question and plan-review workspaces during same-session hydration - submit from live session state and retain dirty answers across response identity changes - add desktop and mobile regression coverage for deferred hydration and stale Stop polling - document the ownership race and add a patch changeset Files changed: .changeset/fn-9117-planning-turn-ownership.md | 7 + .../suite-only-flakes-observed-register.md | 24 ++++ .../dashboard/app/components/PlanningModeModal.tsx | 141 ++++++++++++--------- .../PlanningModeModal.planning-flow.test.tsx | 132 +++++++++++++++++++ .../PlanningModeModal.ui-interactions.test.tsx | 20 +++ 5 files changed, 265 insertions(+), 59 deletions(-) Fusion-Task-Id: FN-9117 Fusion-Task-Lineage: 18d63f63-b532-49eb-a6d1-241e31c5b2c7 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-9117-planning-turn-ownership.md
Normal file
7
.changeset/fn-9117-planning-turn-ownership.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Preserve typed Planning Mode answers during late session hydration.
|
||||||
|
category: fix
|
||||||
|
dev: Binds visible question submission to the live planning turn and preserves dirty answers during same-session refresh.
|
||||||
@@ -187,3 +187,27 @@ The flake is structurally removed rather than stabilized: every hydration/recove
|
|||||||
**Resolved 2026-08-16 (FN-9120): both a timing-sensitive test assertion and a product race.** The original third phase synchronously asserted after `userEvent.type` while the loaded lane still rendered populated rows, even though its once queue had not shifted. Independently, the production effect had no cleanup or request identity, so a close/reopen, project change, or unmount could let a stale fetch write roster/loading/error state; initial `loadingAgents=false` also exposed terminal empty copy before the first effect.
|
**Resolved 2026-08-16 (FN-9120): both a timing-sensitive test assertion and a product race.** The original third phase synchronously asserted after `userEvent.type` while the loaded lane still rendered populated rows, even though its once queue had not shifted. Independently, the production effect had no cleanup or request identity, so a close/reopen, project change, or unmount could let a stale fetch write roster/loading/error state; initial `loadingAgents=false` also exposed terminal empty copy before the first effect.
|
||||||
|
|
||||||
The component now owns an explicit idle/loading/loaded/failed phase and fences each request with an epoch plus cleanup. A current successful reload removes selected IDs absent from its roster. The test uses controlled deferred promises in a single persistently-mounted modal, proves close/reopen/project ordering, failure and unmount fencing, duplicate-name/selection reconciliation, and desktop/mobile empty-state copy invariants without retries, sleeps, waits around the old assertion, or mock re-pinning.
|
The component now owns an explicit idle/loading/loaded/failed phase and fences each request with an epoch plus cleanup. A current successful reload removes selected IDs absent from its roster. The test uses controlled deferred promises in a single persistently-mounted modal, proves close/reopen/project ordering, failure and unmount fencing, duplicate-name/selection reconciliation, and desktop/mobile empty-state copy invariants without retries, sleeps, waits around the old assertion, or mock re-pinning.
|
||||||
|
|
||||||
|
## 10. Planning Mode loaded-turn affordance ownership
|
||||||
|
|
||||||
|
- **Files:** `packages/dashboard/app/components/__tests__/PlanningModeModal.planning-flow.test.tsx`, `PlanningModeModal.ui-interactions.test.tsx`
|
||||||
|
- **Exact cases:** `opens Plan preview without submitting and preserves the current mobile answer on return`; `can restart initial planning after stopping its first generation`; `can refine a stopped initial plan into the first question`; both desktop/mobile rows of `keeps five substantive choices and one Other usable on %s`; `submits an answer after deferred same-session hydration on %s`; and FN-9117's `keeps post-Stop plan review when a pre-Stop loading poll resolves on %s`.
|
||||||
|
- **Observed tree/SHA:** original reports at `9a9e591b72`; completed remediation tree `603373b93a`.
|
||||||
|
|
||||||
|
**Resolved 2026-08-16 (FN-9117): Product ownership race, not a timeout defect.** `QuestionForm` rendered from `workspaceQuestion`, while submit formerly branched on a closed-over `view`; a late hydration could therefore drop an enabled Next action. It also restored every new `initialResponse` object identity, overwriting a dirty same-question draft and disabling the mobile Next-question path. FN-9117 binds submit to the live session/question state and preserves a dirty same-question draft.
|
||||||
|
|
||||||
|
The Stop audit also confirmed the recovery-poll ownership hazard: Stop invalidates loading state then can restore the same session id for a question or summary terminal view. FN-9116's load-and-turn fence now rejects a poll started before that boundary. FN-9117 adds real-modal desktop and mobile deferred-poll coverage: fake timer time starts the 8-second poll, a deferred stale durable question resolves after Stop, and post-Stop plan review remains intact. The pre-FN-9116 source had effect-cleanup cancellation once terminal React state committed; the epoch fence closes the earlier render/cleanup interval structurally. No timeout, retry, widened wait, sleep, weakened assertion, or quarantine was used.
|
||||||
|
|
||||||
|
This completes the two Stop reports rather than deferring them as unreproduced. A same-session `ai_session:updated` rehydrate was the remaining transient-unmount path: `loadSession` cleared `workspaceQuestion` before its fetch resolved, unmounting `QuestionForm` and discarding the dirty answer. It now preserves an active question/plan-review workspace only for a refresh of that same session; a different session still enters the neutral loader. The real-modal deferred-hydration test uses per-character `userEvent.type` on desktop and the mobile Other choice, then asserts the exact `respondToPlanning` payload after the controlled commit.
|
||||||
|
|
||||||
|
It is the companion to entries 4, 5, and 8: FN-8936 fixed detached test-node handoff; FN-9116 fences duplicate-response and recovery writers; FN-9117 ensures visible question controls use the live turn and retain operator drafts.
|
||||||
|
|
||||||
|
| verification | result |
|
||||||
|
|---|---|
|
||||||
|
| targeted planning-flow + ui-interactions ×3 | **passed** (84 planning-flow tests, 20 UI-interaction tests) |
|
||||||
|
| all `PlanningModeModal.*` sibling suites | **passed** |
|
||||||
|
| `test:quality:app:backfill` aggregate attempt | shards 1–3 passed; initial 300s bound ended during shard 4, which passed when run directly |
|
||||||
|
| `test:quality:app:backfill` aggregate attempts 2–3 | blocked by repeated unrelated `CreateRoomModal` search-state failures; filed as FN-9121 with full logs `/tmp/fn-9117-backfill-run-{2,3}.log` |
|
||||||
|
| `pnpm lint`, `pnpm verify:fast`, `pnpm build` | **passed** |
|
||||||
|
|
||||||
|
No UI surface changed; this was a state-ownership and regression-coverage repair. The existing patch changeset remains applicable because Planning Mode behavior is user-visible.
|
||||||
|
|||||||
@@ -2047,6 +2047,15 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
const loadSession = useCallback(
|
const loadSession = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
const loadEpoch = ++planningSessionLoadEpochRef.current;
|
const loadEpoch = ++planningSessionLoadEpochRef.current;
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-16-08:54:
|
||||||
|
An idle-session SSE refresh rehydrates the same server session while its question remains
|
||||||
|
actionable. Do not transiently clear that workspace: unmounting QuestionForm discards a
|
||||||
|
typed or selected local answer before its enabled Next action can submit it. A different
|
||||||
|
session still takes the neutral loader so its prior turn never remains visible.
|
||||||
|
*/
|
||||||
|
const preservesActiveWorkspace = currentSessionIdRef.current === sessionId
|
||||||
|
&& (viewRef.current.type === "question" || viewRef.current.type === "plan_review");
|
||||||
streamConnectionRef.current?.close();
|
streamConnectionRef.current?.close();
|
||||||
streamConnectionRef.current = null;
|
streamConnectionRef.current = null;
|
||||||
currentSessionIdRef.current = sessionId;
|
currentSessionIdRef.current = sessionId;
|
||||||
@@ -2060,23 +2069,25 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
review with a working View task button. Clear it at load start; the complete branch
|
review with a working View task button. Clear it at load start; the complete branch
|
||||||
restores it for the session actually being loaded.
|
restores it for the session actually being loaded.
|
||||||
*/
|
*/
|
||||||
setLinkedTaskId(null);
|
if (!preservesActiveWorkspace) {
|
||||||
setLinkedTask(null);
|
setLinkedTaskId(null);
|
||||||
setStreamingOutput("");
|
setLinkedTask(null);
|
||||||
setResponseHistory([]);
|
setStreamingOutput("");
|
||||||
setConversationHistory([]);
|
setResponseHistory([]);
|
||||||
setEditedSummary(null);
|
setConversationHistory([]);
|
||||||
setRunningSummary(null);
|
setEditedSummary(null);
|
||||||
setWorkspaceQuestion(null);
|
setRunningSummary(null);
|
||||||
setLoadedSessionTitle(null);
|
setWorkspaceQuestion(null);
|
||||||
setIsRetrying(false);
|
setLoadedSessionTitle(null);
|
||||||
setIsRefiningSummary(false);
|
setIsRetrying(false);
|
||||||
refineSummaryInFlightRef.current = false;
|
setIsRefiningSummary(false);
|
||||||
setGenerationStartTime(null);
|
refineSummaryInFlightRef.current = false;
|
||||||
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
|
setGenerationStartTime(null);
|
||||||
// not the generation pane — only a fetched status of "generating" enters `loading` below.
|
// FNXC:PlanningMode 2026-07-23-00:00: hydrate-from-DB shows the neutral session loader,
|
||||||
viewRef.current = { type: "session_loading" };
|
// not the generation pane — only a fetched status of "generating" enters `loading` below.
|
||||||
setView({ type: "session_loading" });
|
viewRef.current = { type: "session_loading" };
|
||||||
|
setView({ type: "session_loading" });
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await fetchAiSession(sessionId);
|
const session = await fetchAiSession(sessionId);
|
||||||
@@ -2827,44 +2838,20 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
|
|
||||||
const handleSubmitResponse = useCallback(
|
const handleSubmitResponse = useCallback(
|
||||||
async (responses: QuestionResponse) => {
|
async (responses: QuestionResponse) => {
|
||||||
if (view.type !== "question") return;
|
const sessionId = currentSessionIdRef.current;
|
||||||
|
const activeQuestion = workspaceQuestion;
|
||||||
const { session } = view;
|
if (!sessionId || !activeQuestion) {
|
||||||
const sessionId = session.sessionId;
|
setError(t("planning.noActiveQuestion", "No active question is available. Wait for the interview to resume."));
|
||||||
const activeQuestion = session.currentQuestion;
|
|
||||||
if (!activeQuestion) {
|
|
||||||
/*
|
|
||||||
FNXC:PlanningQuestionRegeneration 2026-07-23-21:40:
|
|
||||||
Submitting with no active question is no longer a dead-end "No active question in
|
|
||||||
session" error. The server regenerates a fresh interview question from the accumulated
|
|
||||||
context, so forward the input and enter the loading state; the SSE question event (or
|
|
||||||
the HTTP payload for restored sessions without a live stream) restores the view. No
|
|
||||||
optimistic history entry is recorded because there is no question to pair it with.
|
|
||||||
*/
|
|
||||||
setError(null);
|
|
||||||
resetPlanningAutoRetryBudget();
|
|
||||||
setGenerationActivity("question");
|
|
||||||
setGenerationStartTime(Date.now());
|
|
||||||
setView({ type: "loading" });
|
|
||||||
setStreamingOutput("");
|
|
||||||
currentSessionIdRef.current = sessionId;
|
|
||||||
if (!streamConnectionRef.current?.isConnected()) {
|
|
||||||
connectToPlanningStream(sessionId);
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const response = await respondToPlanning(sessionId, responses, projectId);
|
|
||||||
const responseQuestion = "type" in response ? response.data : response.currentQuestion;
|
|
||||||
if (responseQuestion) {
|
|
||||||
const nextQuestion = normalizeQuestionOptions(responseQuestion);
|
|
||||||
setWorkspaceQuestion(nextQuestion);
|
|
||||||
setView({ type: "question", session: { sessionId, currentQuestion: nextQuestion, summary: runningSummaryRef.current } });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
setError(getErrorMessage(err) || t("planning.failedSubmitResponse", "Failed to submit response"));
|
|
||||||
setView({ type: "question", session: { sessionId, currentQuestion: null, summary: runningSummaryRef.current } });
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-16-06:28:
|
||||||
|
The form is rendered from workspaceQuestion and remains visible beneath the loading overlay.
|
||||||
|
Submit must use that live question and session ref, not a render-time view snapshot: a late
|
||||||
|
hydration may change view between an enabled Next render and its user event, and must never
|
||||||
|
silently discard that answer.
|
||||||
|
*/
|
||||||
|
const session = { sessionId, currentQuestion: activeQuestion, summary: runningSummaryRef.current };
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
const responseTurnEpoch = ++planningTurnEpochRef.current;
|
const responseTurnEpoch = ++planningTurnEpochRef.current;
|
||||||
@@ -3000,7 +2987,7 @@ export function PlanningModeModal({ isOpen, onClose, onTaskCreated, onTasksCreat
|
|||||||
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
|
setView({ type: "question", session: { ...session, summary: runningSummaryRef.current } });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, t, view]
|
[connectToPlanningStream, conversationHistory, editingQuestionId, projectId, resetPlanningAutoRetryBudget, t, workspaceQuestion]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleStopGeneration = useCallback(async () => {
|
const handleStopGeneration = useCallback(async () => {
|
||||||
@@ -4433,6 +4420,11 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
const [commentValue, setCommentValue] = useState("");
|
const [commentValue, setCommentValue] = useState("");
|
||||||
const [otherValue, setOtherValue] = useState("");
|
const [otherValue, setOtherValue] = useState("");
|
||||||
const [isOtherSelected, setIsOtherSelected] = useState(false);
|
const [isOtherSelected, setIsOtherSelected] = useState(false);
|
||||||
|
const dirtyResponseRef = useRef(false);
|
||||||
|
const restoredQuestionIdRef = useRef<string | null>(null);
|
||||||
|
const markResponseDirty = useCallback(() => {
|
||||||
|
dirtyResponseRef.current = true;
|
||||||
|
}, []);
|
||||||
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
|
const { ref: textAnswerAutosizeRef } = useAutosizeTextarea({
|
||||||
value: textValue,
|
value: textValue,
|
||||||
minHeight: 120,
|
minHeight: 120,
|
||||||
@@ -4507,9 +4499,18 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
|
|
||||||
// Restore a selected history answer so editing is a direct, non-destructive operation.
|
// Restore a selected history answer so editing is a direct, non-destructive operation.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (restoredQuestionIdRef.current === question.id && dirtyResponseRef.current) return;
|
||||||
const prior = initialResponse ?? {};
|
const prior = initialResponse ?? {};
|
||||||
const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : "";
|
const other = typeof prior[PLANNING_OTHER_RESPONSE_KEY] === "string" ? prior[PLANNING_OTHER_RESPONSE_KEY] : "";
|
||||||
const text = prior[question.id];
|
const text = prior[question.id];
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-16-06:28:
|
||||||
|
Hydration can replace the parent response object after a user starts answering the same
|
||||||
|
question. Restore durable data only until that local turn is dirty; object identity churn
|
||||||
|
must not erase an enabled form's unsubmitted answer or disable Next question.
|
||||||
|
*/
|
||||||
|
dirtyResponseRef.current = false;
|
||||||
|
restoredQuestionIdRef.current = question.id;
|
||||||
setResponse(prior);
|
setResponse(prior);
|
||||||
setTextValue(typeof text === "string" ? text : "");
|
setTextValue(typeof text === "string" ? text : "");
|
||||||
setCommentValue(typeof prior._comment === "string" ? prior._comment : "");
|
setCommentValue(typeof prior._comment === "string" ? prior._comment : "");
|
||||||
@@ -4571,7 +4572,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
className="planning-textarea"
|
className="planning-textarea"
|
||||||
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
|
placeholder={t("planning.typeAnswerPlaceholder", "Type your answer here...")}
|
||||||
value={textValue}
|
value={textValue}
|
||||||
onChange={(e) => setTextValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
|
setTextValue(e.target.value);
|
||||||
|
}}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
|
if (e.key === "Enter" && !e.shiftKey && textValue.trim()) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -4593,6 +4597,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
value={option.id}
|
value={option.id}
|
||||||
checked={response[question.id] === option.id && !isOtherSelected}
|
checked={response[question.id] === option.id && !isOtherSelected}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(false);
|
setIsOtherSelected(false);
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
setResponse({ [question.id]: option.id });
|
setResponse({ [question.id]: option.id });
|
||||||
@@ -4614,6 +4619,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
value={PLANNING_OTHER_OPTION_ID}
|
value={PLANNING_OTHER_OPTION_ID}
|
||||||
checked={isOtherSelected}
|
checked={isOtherSelected}
|
||||||
onChange={() => {
|
onChange={() => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(true);
|
setIsOtherSelected(true);
|
||||||
setResponse({});
|
setResponse({});
|
||||||
}}
|
}}
|
||||||
@@ -4630,7 +4636,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
data-testid="planning-other-input"
|
data-testid="planning-other-input"
|
||||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||||
value={otherValue}
|
value={otherValue}
|
||||||
onChange={(e) => setOtherValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
|
setOtherValue(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4648,6 +4657,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
value={option.id}
|
value={option.id}
|
||||||
checked={selected.includes(option.id)}
|
checked={selected.includes(option.id)}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
const newSelected = e.target.checked
|
const newSelected = e.target.checked
|
||||||
? [...selected, option.id]
|
? [...selected, option.id]
|
||||||
: selected.filter((id) => id !== option.id);
|
: selected.filter((id) => id !== option.id);
|
||||||
@@ -4670,6 +4680,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
value={PLANNING_OTHER_OPTION_ID}
|
value={PLANNING_OTHER_OPTION_ID}
|
||||||
checked={isOtherSelected}
|
checked={isOtherSelected}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(e.target.checked);
|
setIsOtherSelected(e.target.checked);
|
||||||
if (!e.target.checked) {
|
if (!e.target.checked) {
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
@@ -4688,7 +4699,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
data-testid="planning-other-input"
|
data-testid="planning-other-input"
|
||||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||||
value={otherValue}
|
value={otherValue}
|
||||||
onChange={(e) => setOtherValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
|
setOtherValue(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4701,6 +4715,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
<button
|
<button
|
||||||
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
|
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(false);
|
setIsOtherSelected(false);
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
setResponse({ [question.id]: true });
|
setResponse({ [question.id]: true });
|
||||||
@@ -4712,6 +4727,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
<button
|
<button
|
||||||
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
|
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(false);
|
setIsOtherSelected(false);
|
||||||
setOtherValue("");
|
setOtherValue("");
|
||||||
setResponse({ [question.id]: false });
|
setResponse({ [question.id]: false });
|
||||||
@@ -4724,6 +4740,7 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
|
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
|
||||||
data-testid="planning-option-other"
|
data-testid="planning-option-other"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
markResponseDirty();
|
||||||
setIsOtherSelected(true);
|
setIsOtherSelected(true);
|
||||||
setResponse({});
|
setResponse({});
|
||||||
}}
|
}}
|
||||||
@@ -4740,7 +4757,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
data-testid="planning-other-input"
|
data-testid="planning-other-input"
|
||||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||||
value={otherValue}
|
value={otherValue}
|
||||||
onChange={(e) => setOtherValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
|
setOtherValue(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -4759,7 +4779,10 @@ export function QuestionForm({ question: rawQuestion, initialResponse, onSubmit,
|
|||||||
className="planning-textarea"
|
className="planning-textarea"
|
||||||
placeholder={t("planning.additionalCommentsPlaceholder", "Add any extra context or direction...")}
|
placeholder={t("planning.additionalCommentsPlaceholder", "Add any extra context or direction...")}
|
||||||
value={commentValue}
|
value={commentValue}
|
||||||
onChange={(e) => setCommentValue(e.target.value)}
|
onChange={(e) => {
|
||||||
|
markResponseDirty();
|
||||||
|
setCommentValue(e.target.value);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal";
|
import { PlanningModeModal, resetPlanningAutoRetryAttemptsForTests } from "../PlanningModeModal";
|
||||||
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
import { mockCreatePlanningDraft, mockFetchAiSession, mockFetchAiSessions, mockRespondToPlanning, mockRetryPlanningSession, mockStartPlanningStreaming, mockStopPlanningGeneration, mockValidatePlanningSession, mockCreateTaskFromPlanning, mockTasks, mockSummary } from "./PlanningModeModal.test-helpers";
|
||||||
|
|
||||||
@@ -649,6 +650,72 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
expect(mockConnectPlanningStream).not.toHaveBeenCalled();
|
expect(mockConnectPlanningStream).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-16-08:54:
|
||||||
|
A same-session background hydration may start after a user has answered but before Next is
|
||||||
|
pressed. Keep the real desktop and mobile modal question mounted through that deferred commit,
|
||||||
|
so the live action submits the locally chosen payload rather than a discarded draft.
|
||||||
|
*/
|
||||||
|
it.each(["desktop", "mobile"] as const)("submits an answer after deferred same-session hydration on %s", async (viewport) => {
|
||||||
|
mockViewportMode.mockReturnValue(viewport);
|
||||||
|
const question = viewport === "desktop"
|
||||||
|
? { id: "q-deferred-text", type: "text" as const, question: "What must stay actionable?" }
|
||||||
|
: {
|
||||||
|
id: "q-deferred-choice",
|
||||||
|
type: "single_select" as const,
|
||||||
|
question: "Which mobile answer must stay actionable?",
|
||||||
|
options: [{ id: "one", label: "First direction" }],
|
||||||
|
};
|
||||||
|
let resolveHydration!: (session: Record<string, unknown>) => void;
|
||||||
|
mockFetchAiSession.mockImplementationOnce(() => Promise.resolve({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify(question),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
})).mockImplementationOnce(() => new Promise((resolve) => { resolveHydration = resolve; }));
|
||||||
|
mockRespondToPlanning.mockResolvedValue({ summary: summaryWithRefinements, currentQuestion: null });
|
||||||
|
|
||||||
|
renderSession();
|
||||||
|
await screen.findByText(question.question);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
if (viewport === "desktop") {
|
||||||
|
await user.type(screen.getByPlaceholderText("Type your answer here..."), "Keep this response");
|
||||||
|
} else {
|
||||||
|
await user.click(screen.getByRole("radio", { name: /other \(write your own\)/i }));
|
||||||
|
await user.type(screen.getByTestId("planning-other-input"), "Keep this mobile response");
|
||||||
|
}
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
|
||||||
|
|
||||||
|
mockPlanningSse.events?.["ai_session:updated"]?.(new MessageEvent("ai_session:updated", {
|
||||||
|
data: JSON.stringify({ ...base, type: "planning", status: "awaiting_input" }),
|
||||||
|
}));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveHydration({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify(question),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
|
||||||
|
await user.click(screen.getByRole("button", { name: "Next" }));
|
||||||
|
await waitFor(() => expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||||
|
"session-1",
|
||||||
|
viewport === "desktop"
|
||||||
|
? { "q-deferred-text": "Keep this response" }
|
||||||
|
: { _other: "Keep this mobile response" },
|
||||||
|
"project-1",
|
||||||
|
));
|
||||||
|
});
|
||||||
|
|
||||||
it("opens question, answer, and collapsed AI reasoning history without a Sessions toggle", async () => {
|
it("opens question, answer, and collapsed AI reasoning history without a Sessions toggle", async () => {
|
||||||
mockFetchAiSession.mockResolvedValue({
|
mockFetchAiSession.mockResolvedValue({
|
||||||
...base,
|
...base,
|
||||||
@@ -1766,6 +1833,71 @@ describe("PlanningModeModal sequential flow", () => {
|
|||||||
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
|
expect(mockConnectPlanningStream).toHaveBeenCalledTimes(2);
|
||||||
expect(screen.queryByText("Stream error")).toBeNull();
|
expect(screen.queryByText("Stream error")).toBeNull();
|
||||||
});
|
});
|
||||||
|
/*
|
||||||
|
FNXC:PlanningMode 2026-08-16-08:20:
|
||||||
|
Stopping a generating session restores its summary view and intentionally reuses the session id.
|
||||||
|
A recovery-poll request begun before Stop must still lose its load/turn ownership, otherwise its
|
||||||
|
stale durable question can replace the terminal plan review after the operator has stopped.
|
||||||
|
*/
|
||||||
|
it.each(["desktop", "mobile"] as const)("keeps post-Stop plan review when a pre-Stop loading poll resolves on %s", async (viewport) => {
|
||||||
|
mockViewportMode.mockReturnValue(viewport);
|
||||||
|
let resolveStalePoll!: (session: Record<string, unknown>) => void;
|
||||||
|
let fetchCount = 0;
|
||||||
|
mockFetchAiSession.mockImplementation(() => {
|
||||||
|
fetchCount += 1;
|
||||||
|
if (fetchCount === 1) {
|
||||||
|
return Promise.resolve({
|
||||||
|
...base,
|
||||||
|
status: "generating",
|
||||||
|
currentQuestion: null,
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: JSON.stringify({ generationPurpose: "initial_plan" }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => { resolveStalePoll = resolve; });
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.useFakeTimers();
|
||||||
|
renderSession();
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(8000);
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(fetchCount).toBe(2);
|
||||||
|
|
||||||
|
let resolveStop!: (value: { success: boolean }) => void;
|
||||||
|
mockStopPlanningGeneration.mockImplementationOnce(() => new Promise((resolve) => { resolveStop = resolve; }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Stop" }));
|
||||||
|
await act(async () => {
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(mockStopPlanningGeneration).toHaveBeenCalledWith("session-1", "project-1");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
// Resolve Stop first: it restores the session ref before React cleans up the loading poll.
|
||||||
|
// The old poll then resolves in that same commit window, when only the load epoch can reject it.
|
||||||
|
resolveStop({ success: true });
|
||||||
|
await Promise.resolve();
|
||||||
|
resolveStalePoll({
|
||||||
|
...base,
|
||||||
|
status: "awaiting_input",
|
||||||
|
currentQuestion: JSON.stringify({ id: "q-stale-poll", type: "text", question: "What did stale polling ask?" }),
|
||||||
|
result: JSON.stringify(summaryWithRefinements),
|
||||||
|
inputPayload: "{}",
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByTestId("planning-plan-review")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("What did stale polling ask?")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("can restart initial planning after stopping its first generation", async () => {
|
it("can restart initial planning after stopping its first generation", async () => {
|
||||||
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
|
render(<PlanningModeModal isOpen onClose={vi.fn()} onTaskCreated={vi.fn()} onTasksCreated={vi.fn()} tasks={mockTasks} projectId="project-1" />);
|
||||||
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
|
fireEvent.change(screen.getByLabelText("What do you want to build?"), { target: { value: "Build secure accounts" } });
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
@@ -133,6 +134,25 @@ describe("PlanningModeModal sequential layout", () => {
|
|||||||
expect(onSubmit).toHaveBeenCalledWith({ direction: "scope" });
|
expect(onSubmit).toHaveBeenCalledWith({ direction: "scope" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a typed answer when same-question hydration replaces the committed response", async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const question = { id: "q-late-hydration", type: "text" as const, question: "What should remain yours?" };
|
||||||
|
const { rerender } = render(<QuestionForm question={question} onSubmit={vi.fn()} />);
|
||||||
|
|
||||||
|
const answer = screen.getByPlaceholderText("Type your answer here...");
|
||||||
|
await user.type(answer, "Keep this draft");
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
|
||||||
|
|
||||||
|
rerender(<QuestionForm
|
||||||
|
question={question}
|
||||||
|
initialResponse={{ "q-late-hydration": "Durable answer from a late hydration" }}
|
||||||
|
onSubmit={vi.fn()}
|
||||||
|
/>);
|
||||||
|
|
||||||
|
expect(screen.getByPlaceholderText("Type your answer here...")).toHaveValue("Keep this draft");
|
||||||
|
expect(screen.getByRole("button", { name: "Next" })).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
FNXC:PlanningMode 2026-08-07-03:23:
|
FNXC:PlanningMode 2026-08-07-03:23:
|
||||||
Model guidance normally asks for 3–5 substantive alternatives but is not a render cap.
|
Model guidance normally asks for 3–5 substantive alternatives but is not a render cap.
|
||||||
|
|||||||
Reference in New Issue
Block a user