FN-7314: reuse shared planner question UI

Reuse the existing question-response modal UI inside task-detail planner Chat clarification flows.

- Render planner fn_ask_question tool calls with ChatQuestionResponse, including answered read-only and duplicate-pending states.
- Preserve steering-tool confirmations while sending submitted clarification answers back through planner Chat.
- Extend parser and modal tests for multi-question payloads, submitted answers, duplicate prompts, and empty dependency labels.
- Add a patch changeset for the published Fusion package.

Files changed:
 .changeset/fn-7314-planner-question-ui.md          |  7 ++
 .../app/components/TaskPlannerChatTab.css          |  9 +++
 .../app/components/TaskPlannerChatTab.tsx          | 76 ++++++++++++++++--
 ...etailModal.responsive-and-dependencies.test.tsx |  3 +
 .../__tests__/TaskPlannerChatTab.test.tsx          | 89 ++++++++++++++++++----
 .../utils/__tests__/parseQuestionToolCall.test.ts  | 10 ++-
 .../dashboard/app/utils/parseQuestionToolCall.ts   |  6 +-
 7 files changed, 176 insertions(+), 24 deletions(-)

Fusion-Task-Id: FN-7314

Fusion-Task-Lineage: bd5da323-0ba1-4094-a965-cc2801a1c9b7

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-30 19:21:53 -07:00
parent cb0d38a0c6
commit 0915377a1c
7 changed files with 176 additions and 24 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Reuse the shared question UI for task-detail planner Chat clarification prompts.
category: fix
dev: Task planner Chat now renders fn_ask_question prompts through ChatQuestionResponse and marks submitted answers read-only.

View File

@@ -180,6 +180,11 @@
color: var(--text-muted);
}
.task-planner-chat-message .chat-question-response {
max-width: 100%;
overflow-wrap: anywhere;
}
.task-planner-chat-composer {
display: flex;
align-items: flex-end;
@@ -219,4 +224,8 @@
.task-planner-chat-message {
max-width: 100%;
}
.task-planner-chat-message .chat-question-response {
margin-inline: 0;
}
}

View File

@@ -8,7 +8,7 @@ import { useTranslation } from "react-i18next";
import type { ToastType } from "../hooks/useToast";
import type { ToolCallInfo } from "../hooks/chatTypes";
import { ensureTaskPlannerChatSession, fetchChatMessages, fetchTaskDetail, streamChatResponse } from "../api";
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
import { parseQuestionToolCall, type ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
import { markdownComponents } from "./AgentLogViewer";
import { ChatQuestionResponse } from "./ChatQuestionResponse";
import "./TaskPlannerChatTab.css";
@@ -24,6 +24,13 @@ interface TaskPlannerChatTabProps {
type ComposerState = "idle" | "sending";
type PlannerQuestionRenderState = {
parsed: ParsedQuestionToolCall;
answered: boolean;
submittedAnswer?: string;
hiddenDuplicate: boolean;
};
interface StarterPromptDefinition {
id: string;
labelKey: string;
@@ -170,6 +177,57 @@ function extractToolCalls(message: ChatMessage): ToolCallInfo[] {
.filter((toolCall): toolCall is ToolCallInfo => toolCall !== null);
}
function getPlannerQuestionKey(parsed: ParsedQuestionToolCall): string {
return JSON.stringify(parsed.questions.map((question) => ({
id: question.id,
type: question.type,
question: question.question,
options: question.options?.map((option) => [option.id, option.label]),
})));
}
function isQuestionAnswerFor(message: ChatMessage, parsed: ParsedQuestionToolCall): boolean {
if (message.role !== "user") return false;
const trimmed = message.content.trim();
if (!trimmed) return false;
return parsed.questions.some((question) => trimmed.includes(`> Q: ${question.question}`));
}
function buildPlannerQuestionRenderStates(messages: readonly ChatMessage[]): Map<string, PlannerQuestionRenderState> {
const states = new Map<string, PlannerQuestionRenderState>();
const latestUnansweredByQuestion = new Map<string, string>();
messages.forEach((message, messageIndex) => {
if (message.role !== "assistant") return;
extractToolCalls(message).forEach((toolCall, toolCallIndex) => {
const parsed = parseQuestionToolCall(toolCall);
if (!parsed) return;
const stateKey = `${message.id}:${toolCallIndex}`;
const questionKey = getPlannerQuestionKey(parsed);
const nextUserAnswer = messages.slice(messageIndex + 1).find((candidate) => isQuestionAnswerFor(candidate, parsed));
const answered = Boolean(nextUserAnswer);
if (!answered) {
const previousPendingKey = latestUnansweredByQuestion.get(questionKey);
if (previousPendingKey) {
const previous = states.get(previousPendingKey);
if (previous) {
states.set(previousPendingKey, { ...previous, hiddenDuplicate: true });
}
}
latestUnansweredByQuestion.set(questionKey, stateKey);
}
states.set(stateKey, {
parsed,
answered,
submittedAnswer: nextUserAnswer?.content,
hiddenDuplicate: false,
});
});
});
return states;
}
export function TaskPlannerChatTab({ task, projectId, active, planningModel, addToast, onTaskUpdated }: TaskPlannerChatTabProps) {
const { t } = useTranslation("app");
const [sessionId, setSessionId] = useState<string | null>(null);
@@ -386,6 +444,7 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
const canSend = draft.trim().length > 0 && composerState !== "sending";
const showEmptyState = historyLoaded && !loading && !error && messages.length === 0;
const questionRenderStates = useMemo(() => buildPlannerQuestionRenderStates(messages), [messages]);
const starterPrompts = useMemo(() => {
const seenLabels = new Set<string>();
return TASK_PLANNER_CHAT_STARTER_PROMPTS.flatMap((prompt) => {
@@ -419,6 +478,9 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
Stream callbacks are guarded by a per-send token because closing an EventSource/stream is not enough to prevent queued text, tool, done, error, or fallback refresh callbacks from mutating the newly selected task's Chat tab.
FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
Planner-generated clarification questions in the task-detail Chat transcript must reuse ChatQuestionResponse instead of bespoke chat text. Submitted answers stay in the planner-chat lane as ordinary follow-up user messages, render the prior question read-only, and duplicate refetched pending tool calls hide older live forms so users never see competing submit affordances.
*/
return (
<section className="task-planner-chat" aria-label={t("taskDetail.plannerChat.label", "Planner chat")} data-testid="task-planner-chat-panel">
@@ -504,15 +566,15 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add
</div>
);
}
const parsedQuestion = parseQuestionToolCall(toolCall);
if (!parsedQuestion) return null;
const answered = message.id !== "streaming-assistant" && message !== messages[messages.length - 1];
const questionState = questionRenderStates.get(`${message.id}:${index}`);
if (!questionState || questionState.hiddenDuplicate) return null;
return (
<ChatQuestionResponse
key={`${toolCall.toolName}-${index}`}
parsed={parsedQuestion}
answered={answered}
disabled={composerState === "sending" || answered}
parsed={questionState.parsed}
answered={questionState.answered}
submittedAnswer={questionState.submittedAnswer}
disabled={composerState === "sending" || questionState.answered}
compact
onSubmit={(answerText) => void sendMessageContent(answerText)}
/>

View File

@@ -100,8 +100,11 @@ describe("TaskDetailModal", () => {
expect(mobileBlock).toContain("flex-direction: column;");
expect(mobileBlock).toContain("align-items: stretch;");
expectBaseRule(css, ".task-planner-chat-starters", "grid-template-columns: repeat(2, minmax(0, 1fr));");
expectBaseRule(css, ".task-planner-chat-message .chat-question-response", "overflow-wrap: anywhere;");
expect(mobileBlock).toContain(".task-planner-chat-starters");
expect(mobileBlock).toContain("grid-template-columns: 1fr;");
expect(mobileBlock).toContain(".task-planner-chat-message .chat-question-response");
expect(mobileBlock).toContain("margin-inline: 0;");
});
it("keeps detail metadata as a single wrapping flex row without mobile column fallbacks", () => {

View File

@@ -64,6 +64,18 @@ function renderPlannerChat(overrides: Partial<React.ComponentProps<typeof TaskPl
);
}
function plannerQuestionMessage(id: string, args: Record<string, unknown>, createdAt = "2026-06-30T00:02:00.000Z") {
return {
id,
sessionId: "chat-planner",
role: "assistant",
content: "Planner needs clarification.",
thinkingOutput: null,
metadata: { toolCalls: [{ toolName: "fn_ask_question", args, isError: false }] },
createdAt,
};
}
describe("TaskPlannerChatTab", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -397,19 +409,7 @@ describe("TaskPlannerChatTab", () => {
it("renders planner question tool calls with the shared answer UI", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({
messages: [
{
id: "assistant-question",
sessionId: "chat-planner",
role: "assistant",
content: "Which path should we use?",
thinkingOutput: null,
metadata: {
toolCalls: [{ toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false }],
},
createdAt: "2026-06-30T00:02:00.000Z",
},
],
messages: [plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] })],
});
renderPlannerChat();
@@ -494,6 +494,69 @@ describe("TaskPlannerChatTab", () => {
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
});
it("renders text, single-select, multi-select, confirm, and missing-option planner questions", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({
messages: [plannerQuestionMessage("assistant-question", {
questions: [
{ id: "text", question: "Describe the risk", type: "text" },
{ id: "single", question: "Pick one", type: "single_select", options: [{ id: "safe", label: "Safe" }] },
{ id: "multi", question: "Pick many", type: "multi_select", options: [{ id: "a", label: "A" }, { id: "b", label: "B" }] },
{ id: "confirm", question: "Proceed?", type: "confirm" },
{ id: "missing", question: "Missing choices", type: "single_select" },
],
})],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
expect(screen.getByTestId("chat-question-response-submit")).toBeDisabled();
await user.type(screen.getByTestId("chat-question-response-text-text"), "Low risk");
await user.click(screen.getByTestId("chat-question-response-option-single-safe"));
await user.click(screen.getByTestId("chat-question-response-option-multi-a"));
await user.click(screen.getByTestId("chat-question-response-option-confirm-no"));
await user.type(screen.getByTestId("chat-question-response-text-missing"), "Use the default");
await user.click(screen.getByTestId("chat-question-response-submit"));
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"> Q: Describe the risk\nLow risk\n\n> Q: Pick one\nSafe\n\n> Q: Pick many\nA\n\n> Q: Proceed?\nNo\n\n> Q: Missing choices\nUse the default",
expect.any(Object),
undefined,
undefined,
{ taskId: "FN-7310" },
);
});
it("renders answered planner questions read-only with the submitted answer", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }),
{ id: "user-answer", sessionId: "chat-planner", role: "user", content: "> Q: Pick a path\nAggressive", thinkingOutput: null, metadata: null, createdAt: "2026-06-30T00:03:00.000Z" },
],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Aggressive");
expect(screen.getByText("Answered")).toBeInTheDocument();
expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
expect(screen.queryByTestId("chat-question-response-option-q-0-opt-1")).not.toBeInTheDocument();
});
it("hides older duplicate pending planner questions after a refetch", async () => {
mockFetchChatMessages.mockResolvedValue({
messages: [
plannerQuestionMessage("assistant-question-old", { question: "Pick a path", options: ["Conservative", "Aggressive"] }, "2026-06-30T00:02:00.000Z"),
plannerQuestionMessage("assistant-question-new", { question: "Pick a path", options: ["Conservative", "Aggressive"] }, "2026-06-30T00:03:00.000Z"),
],
});
renderPlannerChat();
expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1);
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
});
it("shows API errors and re-enables the composer", async () => {
const user = userEvent.setup();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {

View File

@@ -79,10 +79,18 @@ describe("parseQuestionToolCall", () => {
it("falls back for malformed, empty option select, and non-question tools", () => {
expect(parseQuestionToolCall(toolCall("ask_user"))).toBeNull();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "" }))).toBeNull();
expect(parseQuestionToolCall(toolCall("ask_user", { question: "Pick", type: "single_select", options: [] }))).toBeNull();
expect(parseQuestionToolCall(toolCall("read", { question: "No" }))).toBeNull();
});
it("degrades explicit select questions with missing options to answerable text prompts", () => {
expect(parseQuestionToolCall(toolCall("fn_ask_question", { question: "Pick", type: "single_select" }))?.questions[0]).toEqual(
expect.objectContaining({ id: "q-0", type: "text", question: "Pick", options: undefined }),
);
expect(parseQuestionToolCall(toolCall("fn_ask_question", { question: "Pick many", type: "multi_select", options: [] }))?.questions[0]).toEqual(
expect.objectContaining({ id: "q-0", type: "text", question: "Pick many", options: undefined }),
);
});
it("formats selected labels, text, and confirm answers", () => {
const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
questions: [

View File

@@ -40,6 +40,9 @@ export type ChatQuestionAnswers = Record<string, ChatQuestionAnswerValue>;
* FNXC:ChatQuestionResponse 2026-06-16-19:18:
* Chat question tools from multiple agent CLIs and Fusion's native `fn_ask_question` tool must render as structured response controls in ChatView instead of exposing raw JSON in generic tool-call details.
* Keep schema normalization centralized so both chat surfaces recognize the same question tools, synthesize stable ids, and fall back safely when args are malformed.
*
* FNXC:TaskDetailPlannerChat 2026-06-30-23:59:
* Task-detail planner Chat reuses this normalizer so clarification prompts share the established question UI contract with regular Chat and Planning Mode. Explicit select prompts with missing options degrade to text input instead of disappearing, keeping the conversation answerable after partial tool payloads or stale refetches.
*/
export function isQuestionToolName(name: string): boolean {
return QUESTION_TOOL_NAME_SET.has(name.toLowerCase());
@@ -86,9 +89,6 @@ function normalizeQuestion(rawValue: unknown, index: number): ChatQuestion | nul
const options = normalizeOptions(firstArray(raw.options, raw.choices, raw.enum, raw.values));
const explicitType = normalizeQuestionType(firstString(raw.type, raw.questionType, raw.inputType, raw.responseType));
const multiSelect = Boolean(raw.multiSelect ?? raw.multiselect ?? raw.multiple ?? raw.allowMultiple ?? raw.multiple_choice);
if ((explicitType === "single_select" || explicitType === "multi_select") && options.length === 0) {
return null;
}
const type = inferQuestionType(raw, options, explicitType, multiSelect);