FN-7369: add custom answers to Planning Mode confirms
Planning Mode confirm prompts can now preserve a user-authored Other answer instead of forcing Yes or No. - Add an Other option with a textarea to confirm-style Planning Mode questions. - Submit confirm Other answers with the shared `_other` payload and render them in agent/history formatting. - Cover confirm Other behavior in modal flow and formatter tests. - Add a patch changeset for the published CLI package. Files changed: .changeset/fn-7369-confirm-other.md | 7 ++ .../dashboard/app/components/PlanningModeModal.css | 10 ++ .../dashboard/app/components/PlanningModeModal.tsx | 74 +++++++++--- .../PlanningModeModal.planning-flow.test.tsx | 125 +++++++++++++++++++++ .../planning-interview-formatters.test.ts | 27 +++++ packages/dashboard/src/planning.ts | 10 +- 6 files changed, 234 insertions(+), 19 deletions(-) Fusion-Task-Id: FN-7369 Fusion-Task-Lineage: 67b4d943-8ff3-4502-bca1-7e74eb95ac06 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-7369-confirm-other.md
Normal file
7
.changeset/fn-7369-confirm-other.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Let Planning Mode Yes/No questions accept a custom Other answer.
|
||||
category: fix
|
||||
dev: Extends confirm-question handling to preserve user-authored alternatives via `_other`.
|
||||
@@ -1033,6 +1033,12 @@ An empty footer must NOT reserve vertical space or paint its divider band. When
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-confirm-answer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.planning-confirm-group {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
@@ -1732,6 +1738,10 @@ Tablet embedded Planning keeps the desktop two-pane shell, so the summary footer
|
||||
}
|
||||
|
||||
/* Confirm buttons: mobile-friendly sizing */
|
||||
.planning-confirm-group {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.planning-confirm-btn {
|
||||
min-height: 36px;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
|
||||
@@ -2465,7 +2465,14 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
if (question.type === "text") {
|
||||
nextResponse = { [question.id]: textValue };
|
||||
} else if (question.type === "confirm") {
|
||||
nextResponse = { [question.id]: response[question.id] === true };
|
||||
const trimmedOther = otherValue.trim();
|
||||
/*
|
||||
FNXC:PlanningInterview 2026-07-01-00:00:
|
||||
GitHub #1832 requires Yes/No Planning Mode questions to offer an Other path so users can replace a forced boolean with their own answer. Reuse the reserved `_other` payload shape used by structured selection questions instead of inventing a confirm-only custom-answer contract.
|
||||
*/
|
||||
nextResponse = isOtherSelected && trimmedOther.length > 0
|
||||
? { [PLANNING_OTHER_RESPONSE_KEY]: trimmedOther }
|
||||
: { [question.id]: response[question.id] === true };
|
||||
} else if (question.type === "single_select") {
|
||||
const trimmedOther = otherValue.trim();
|
||||
/*
|
||||
@@ -2522,7 +2529,7 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
*/
|
||||
return (Array.isArray(response[question.id] as unknown) && (response[question.id] as unknown[]).length > 0) || (isOtherSelected && otherValue.trim().length > 0);
|
||||
case "confirm":
|
||||
return response[question.id] !== undefined;
|
||||
return response[question.id] !== undefined || (isOtherSelected && otherValue.trim().length > 0);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
@@ -2687,21 +2694,54 @@ function QuestionForm({ question: rawQuestion, progress, historyEntries, onSubmi
|
||||
)}
|
||||
|
||||
{question.type === "confirm" && (
|
||||
<div className="planning-confirm-group">
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === true ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: true })}
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
{t("common.yes", "Yes")}
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false ? "selected" : ""}`}
|
||||
onClick={() => setResponse({ [question.id]: false })}
|
||||
>
|
||||
<X size={18} />
|
||||
{t("common.no", "No")}
|
||||
</button>
|
||||
<div className="planning-confirm-answer">
|
||||
<div className="planning-confirm-group">
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === true && !isOtherSelected ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
setResponse({ [question.id]: true });
|
||||
}}
|
||||
>
|
||||
<CheckCircle size={18} />
|
||||
{t("common.yes", "Yes")}
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${response[question.id] === false && !isOtherSelected ? "selected" : ""}`}
|
||||
onClick={() => {
|
||||
setIsOtherSelected(false);
|
||||
setOtherValue("");
|
||||
setResponse({ [question.id]: false });
|
||||
}}
|
||||
>
|
||||
<X size={18} />
|
||||
{t("common.no", "No")}
|
||||
</button>
|
||||
<button
|
||||
className={`planning-confirm-btn ${isOtherSelected ? "selected" : ""}`}
|
||||
data-testid="planning-option-other"
|
||||
onClick={() => {
|
||||
setIsOtherSelected(true);
|
||||
setResponse({});
|
||||
}}
|
||||
>
|
||||
<HelpCircle size={18} />
|
||||
{t("planning.otherOptionLabel", "Other (write your own)")}
|
||||
</button>
|
||||
</div>
|
||||
{isOtherSelected && (
|
||||
<div className="planning-other-answer">
|
||||
<textarea
|
||||
ref={otherAutosizeRef}
|
||||
className="planning-textarea"
|
||||
data-testid="planning-other-input"
|
||||
placeholder={t("planning.otherOptionPlaceholder", "Write your own answer...")}
|
||||
value={otherValue}
|
||||
onChange={(e) => setOtherValue(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -624,6 +624,131 @@ describe("PlanningModeModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["desktop", "mobile"] as const)("lets confirm questions submit an Other answer on %s", async (viewportMode) => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockViewport(viewportMode);
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.({
|
||||
id: "q-confirm-scope",
|
||||
type: "confirm",
|
||||
question: "Proceed with this scope?",
|
||||
description: "Choose Yes, No, or write a different answer.",
|
||||
});
|
||||
}, 10);
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Proceed with this scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /Yes/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /No/ })).toBeInTheDocument();
|
||||
expect(screen.getByTestId("planning-option-other")).toBeInTheDocument();
|
||||
expect(screen.queryByTestId("planning-other-input")).toBeNull();
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "Continue" });
|
||||
fireEvent.click(screen.getByTestId("planning-option-other"));
|
||||
expect(screen.getByTestId("planning-other-input")).toBeInTheDocument();
|
||||
expect(continueButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId("planning-other-input"), { target: { value: " " } });
|
||||
expect(continueButton).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
||||
target: { value: " Ask a different scoping question " },
|
||||
});
|
||||
expect(continueButton).toBeEnabled();
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
{ _other: "Ask a different scoping question" },
|
||||
undefined,
|
||||
"tab-self",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("clears confirm Other text when switching back to Yes or No", async () => {
|
||||
window.sessionStorage.setItem("fusion-tab-id", "tab-self");
|
||||
mockConnectPlanningStream.mockImplementationOnce((_sessionId: string, _projectId: string | undefined, handlers: any) => {
|
||||
setTimeout(() => {
|
||||
handlers.onQuestion?.({
|
||||
id: "q-confirm-scope",
|
||||
type: "confirm",
|
||||
question: "Proceed with this scope?",
|
||||
});
|
||||
}, 10);
|
||||
return {
|
||||
close: vi.fn(),
|
||||
isConnected: vi.fn().mockReturnValue(true),
|
||||
};
|
||||
});
|
||||
|
||||
render(
|
||||
<PlanningModeModal
|
||||
isOpen={true}
|
||||
onClose={mockOnClose}
|
||||
onTaskCreated={mockOnTaskCreated}
|
||||
onTasksCreated={vi.fn()}
|
||||
tasks={mockTasks}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByPlaceholderText(/e.g., Build a user authentication/), {
|
||||
target: { value: "Build auth system" },
|
||||
});
|
||||
fireEvent.click(screen.getByText("Start Planning"));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Proceed with this scope?")).toBeDefined();
|
||||
});
|
||||
|
||||
const continueButton = screen.getByRole("button", { name: "Continue" });
|
||||
fireEvent.click(screen.getByTestId("planning-option-other"));
|
||||
fireEvent.change(screen.getByTestId("planning-other-input"), {
|
||||
target: { value: "Ask a different scoping question" },
|
||||
});
|
||||
fireEvent.change(screen.getByLabelText("Additional comments (optional)"), {
|
||||
target: { value: "Keep the planner moving" },
|
||||
});
|
||||
expect(continueButton).toBeEnabled();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: /No/ }));
|
||||
expect(screen.queryByTestId("planning-other-input")).toBeNull();
|
||||
fireEvent.click(continueButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockRespondToPlanning).toHaveBeenCalledWith(
|
||||
"session-123",
|
||||
{ "q-confirm-scope": false, _comment: "Keep the planner moving" },
|
||||
undefined,
|
||||
"tab-self",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("shows stop action in loading and stops generation", async () => {
|
||||
let streamHandlers: any;
|
||||
const closeSpy = vi.fn();
|
||||
|
||||
@@ -22,6 +22,12 @@ const multiSelectQuestion: PlanningQuestion = {
|
||||
],
|
||||
};
|
||||
|
||||
const confirmQuestion: PlanningQuestion = {
|
||||
id: "proceed",
|
||||
type: "confirm",
|
||||
question: "Proceed with this plan?",
|
||||
};
|
||||
|
||||
describe("planning interview formatter Other answers", () => {
|
||||
it("formats Other-only single-select answers for the planning agent and Q&A history", () => {
|
||||
const response = { _other: "Run discovery first" };
|
||||
@@ -44,4 +50,25 @@ describe("planning interview formatter Other answers", () => {
|
||||
"A: Speed, Keep humans in review (user's own answer)",
|
||||
);
|
||||
});
|
||||
|
||||
it("formats confirm Yes and No answers without changing boolean semantics", () => {
|
||||
expect(formatResponseForAgent(confirmQuestion, { proceed: true })).toContain("Answer: Yes");
|
||||
expect(formatInterviewQA([{ question: confirmQuestion, response: { proceed: true } }])).toContain("A: Yes");
|
||||
|
||||
expect(formatResponseForAgent(confirmQuestion, { proceed: false })).toContain("Answer: No");
|
||||
expect(formatInterviewQA([{ question: confirmQuestion, response: { proceed: false } }])).toContain("A: No");
|
||||
});
|
||||
|
||||
it("formats confirm Other answers and comments as first-class custom answers", () => {
|
||||
const response = { _other: "Ask a different scoping question", _comment: "Need product input" };
|
||||
|
||||
expect(formatResponseForAgent(confirmQuestion, response)).toContain(
|
||||
"Answer: Ask a different scoping question (user's own answer)",
|
||||
);
|
||||
expect(formatResponseForAgent(confirmQuestion, response)).toContain("Additional context: Need product input");
|
||||
expect(formatInterviewQA([{ question: confirmQuestion, response }])).toContain(
|
||||
"A: Ask a different scoping question (user's own answer)",
|
||||
);
|
||||
expect(formatInterviewQA([{ question: confirmQuestion, response }])).toContain("Comment: Need product input");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2478,7 +2478,13 @@ export function formatResponseForAgent(
|
||||
break;
|
||||
|
||||
case "confirm":
|
||||
formatted = `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
/*
|
||||
FNXC:PlanningInterview 2026-07-01-00:00:
|
||||
GitHub #1832 lets Planning Mode confirm questions submit `_other` instead of a boolean. Preserve that user-authored answer for the agent; otherwise history replay would turn missing confirm ids into an unintended "No".
|
||||
*/
|
||||
formatted = other.length > 0
|
||||
? `Question: ${question.question}\n\nAnswer: ${other} (user's own answer)`
|
||||
: `Question: ${question.question}\n\nAnswer: ${responseValue === true ? "Yes" : "No"}`;
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -2544,7 +2550,7 @@ function formatInterviewAnswer(question: PlanningQuestion, responseValue: unknow
|
||||
}
|
||||
|
||||
case "confirm":
|
||||
return responseValue === true ? "Yes" : "No";
|
||||
return other.length > 0 ? `${other} (user's own answer)` : responseValue === true ? "Yes" : "No";
|
||||
|
||||
default:
|
||||
return JSON.stringify(responseValue);
|
||||
|
||||
Reference in New Issue
Block a user