diff --git a/.changeset/fn-7314-planner-question-ui.md b/.changeset/fn-7314-planner-question-ui.md new file mode 100644 index 0000000000..caed0bd3bd --- /dev/null +++ b/.changeset/fn-7314-planner-question-ui.md @@ -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. diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.css b/packages/dashboard/app/components/TaskPlannerChatTab.css index 4019095507..d58c201719 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.css +++ b/packages/dashboard/app/components/TaskPlannerChatTab.css @@ -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; + } } diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx index 363659b289..619c04fbc3 100644 --- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx +++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx @@ -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 { + const states = new Map(); + const latestUnansweredByQuestion = new Map(); + + 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(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(); 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 (
@@ -504,15 +566,15 @@ export function TaskPlannerChatTab({ task, projectId, active, planningModel, add ); } - 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 ( void sendMessageContent(answerText)} /> diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx index ea2c5b9d48..6b4af0da54 100644 --- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx @@ -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", () => { diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx index 36991fc426..b5b0dbb537 100644 --- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx @@ -64,6 +64,18 @@ function renderPlannerChat(overrides: Partial, 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) => { diff --git a/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts index a53fb37e2c..a56c7e6139 100644 --- a/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts +++ b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts @@ -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: [ diff --git a/packages/dashboard/app/utils/parseQuestionToolCall.ts b/packages/dashboard/app/utils/parseQuestionToolCall.ts index ed0c5b0502..8f701e7354 100644 --- a/packages/dashboard/app/utils/parseQuestionToolCall.ts +++ b/packages/dashboard/app/utils/parseQuestionToolCall.ts @@ -40,6 +40,9 @@ export type ChatQuestionAnswers = Record; * 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);