diff --git a/.changeset/fn-6501-chat-question-response.md b/.changeset/fn-6501-chat-question-response.md
new file mode 100644
index 0000000000..aa078f092b
--- /dev/null
+++ b/.changeset/fn-6501-chat-question-response.md
@@ -0,0 +1,5 @@
+---
+"@runfusion/fusion": minor
+---
+
+Render assistant question tool calls as shared in-chat response cards in full Chat and Quick Chat.
diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md
index 7d354a6a50..c476fc4d33 100644
--- a/docs/dashboard-guide.md
+++ b/docs/dashboard-guide.md
@@ -237,6 +237,7 @@ Chat view provides project-scoped conversations with agents.
- On mobile direct-chat threads, tapping the active title/identity in the thread header opens a lightweight conversation dropdown so you can switch to another direct session without backing out to the sidebar list first; long conversation titles now stay readable in the dropdown via wrapped option text and taller touch-friendly rows.
- On mobile (`max-width: 768px`), chat bubbles are slightly wider in full Chat for improved readability while preserving header/composer gutters.
- Full Chat tool-call summaries now use a denser mobile layout: grouped and single-call collapsed rows keep icon + label + status on one line (Quick Chat-style scanability) while expanded details remain unchanged.
+- Assistant question tool calls now render as a shared in-chat response card instead of a generic tool-call disclosure. The card supports select, multi-select, text, and yes/no prompts, sends the formatted answer back into the same direct or room thread, and renders historical answered questions read-only.
- The desktop Chat view toggle and mobile Chat tab now show an unread-response indicator when a live assistant reply arrives for your active chat thread after you leave Chat; opening Chat clears it immediately.
- Agent-backed chat sessions now expose the same mailbox messaging tools (`fn_send_message`, `fn_read_messages`) used by runtime execution/heartbeat flows whenever the engine `MessageStore` is available; model-only chats continue to run without mailbox tools.
@@ -286,6 +287,7 @@ Quick Chat is an optional floating panel for fast, project-scoped assistant conv
- Queued follow-up messages entered while a Quick Chat response is still streaming now persist per session, so closing/reopening the panel restores the queued text and flushes it once the active response completes.
- Resume lookups still use targeted session queries instead of loading the full active-session list first
- Tool-call summaries in the floating quick-chat panel are intentionally condensed into a single-line header row (especially on small screens) so tool name + status stay scannable without multi-line wrapping
+- Question tool calls use the same shared response card as full Chat, with compact spacing in the floating panel and read-only answered history so Quick Chat can continue agent clarification loops without exposing raw tool JSON.
- On mobile viewports, opening Quick Chat auto-focuses the composer as soon as it is ready so the keyboard opens immediately
- FAB dragging uses pointer events with document-level move/up tracking and a 5px drag threshold so Android touch drags reposition reliably while short taps still open Quick Chat
- Quick Chat now mirrors full Chat tail behavior: if you scroll up, live updates stop auto-following and a **Latest** jump control appears until you jump back down.
diff --git a/packages/dashboard/app/components/ChatQuestionResponse.css b/packages/dashboard/app/components/ChatQuestionResponse.css
new file mode 100644
index 0000000000..90ea673175
--- /dev/null
+++ b/packages/dashboard/app/components/ChatQuestionResponse.css
@@ -0,0 +1,194 @@
+.chat-question-response {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+ margin-block: var(--space-sm);
+ padding: var(--space-md);
+ border: thin solid color-mix(in srgb, var(--accent) 28%, var(--border));
+ border-radius: var(--radius-lg);
+ background: color-mix(in srgb, var(--accent) 6%, var(--bg-secondary));
+ color: var(--text);
+}
+
+.chat-question-response--compact {
+ gap: var(--space-sm);
+ padding: var(--space-sm);
+ border-radius: var(--radius-md);
+}
+
+.chat-question-response__header,
+.chat-question-response__actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-sm);
+}
+
+.chat-question-response__eyebrow,
+.chat-question-response__answered-label,
+.chat-question-response__submitted-label {
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.chat-question-response__answered-label {
+ color: var(--color-success);
+}
+
+.chat-question-response__questions {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-md);
+}
+
+.chat-question-response__question {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+}
+
+.chat-question-response__question-header {
+ margin: 0;
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text-muted);
+}
+
+.chat-question-response__question-text {
+ margin: 0;
+ font-size: 1rem;
+ line-height: 1.3;
+ color: var(--text);
+}
+
+.chat-question-response--compact .chat-question-response__question-text {
+ font-size: 0.875rem;
+}
+
+.chat-question-response__description,
+.chat-question-response__hint {
+ margin: 0;
+ font-size: 0.875rem;
+ line-height: 1.45;
+ color: var(--text-muted);
+}
+
+.chat-question-response__options,
+.chat-question-response__confirm-group {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ margin-block-start: var(--space-xs);
+}
+
+.chat-question-response__confirm-group {
+ flex-direction: row;
+ flex-wrap: wrap;
+}
+
+.chat-question-response__option {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--space-sm);
+ padding: var(--space-sm);
+ border: thin solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ cursor: pointer;
+ transition: border-color var(--transition-fast), background-color var(--transition-fast), color var(--transition-fast);
+}
+
+.chat-question-response__option:hover,
+.chat-question-response__option--selected,
+.chat-question-response__confirm--selected {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, var(--card));
+}
+
+.chat-question-response__option input {
+ margin: calc(var(--space-xs) / 2) 0 0;
+ accent-color: var(--accent);
+}
+
+.chat-question-response__option-content {
+ display: flex;
+ flex-direction: column;
+ gap: calc(var(--space-xs) / 2);
+ min-width: 0;
+}
+
+.chat-question-response__option-label {
+ font-size: 0.875rem;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.chat-question-response__option-description {
+ font-size: 0.875rem;
+ line-height: 1.45;
+ color: var(--text-muted);
+}
+
+.chat-question-response__textarea {
+ width: 100%;
+ min-height: calc(var(--space-xl) * 3);
+ margin-block-start: var(--space-xs);
+ resize: vertical;
+ line-height: 1.45;
+}
+
+.chat-question-response__submit {
+ flex: 0 0 auto;
+}
+
+.chat-question-response__submitted {
+ display: flex;
+ flex-direction: column;
+ gap: var(--space-xs);
+ padding: var(--space-sm);
+ border: thin solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+}
+
+.chat-question-response__submitted pre {
+ margin: 0;
+ white-space: pre-wrap;
+ font-family: var(--font-mono);
+ font-size: 0.875rem;
+ line-height: 1.45;
+ color: var(--text);
+}
+
+.chat-question-response--compact .chat-question-response__actions {
+ align-items: stretch;
+ flex-direction: column;
+}
+
+.chat-question-response--compact .chat-question-response__submit {
+ width: 100%;
+}
+
+@media (max-width: 768px) {
+ .chat-question-response {
+ padding: var(--space-sm);
+ border-radius: var(--radius-md);
+ }
+
+ .chat-question-response__header,
+ .chat-question-response__actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .chat-question-response__confirm-group {
+ flex-direction: column;
+ }
+
+ .chat-question-response__submit {
+ width: 100%;
+ }
+}
diff --git a/packages/dashboard/app/components/ChatQuestionResponse.tsx b/packages/dashboard/app/components/ChatQuestionResponse.tsx
new file mode 100644
index 0000000000..3c54664a13
--- /dev/null
+++ b/packages/dashboard/app/components/ChatQuestionResponse.tsx
@@ -0,0 +1,247 @@
+import "./ChatQuestionResponse.css";
+
+import { useCallback, useLayoutEffect, useMemo, useRef, useState, type MutableRefObject } from "react";
+import { useTranslation } from "react-i18next";
+import type { ChatQuestion, ChatQuestionAnswers, ChatQuestionAnswerValue, ParsedQuestionToolCall } from "../utils/parseQuestionToolCall";
+import { formatQuestionAnswer } from "../utils/parseQuestionToolCall";
+
+export interface ChatQuestionResponseProps {
+ parsed: ParsedQuestionToolCall;
+ answered?: boolean;
+ submittedAnswer?: string;
+ compact?: boolean;
+ disabled?: boolean;
+ onSubmit: (answerText: string, structured: Record) => void;
+}
+
+/**
+ * FNXC:ChatQuestionResponse 2026-06-16-19:25:
+ * In-chat question tools need an attractive shared answer affordance for single-select, multi-select, free-text, and confirm prompts.
+ * Historical or already-answered messages must render read-only so old assistant questions do not keep duplicate live input boxes in regular chat or quick chat.
+ */
+export function ChatQuestionResponse({
+ parsed,
+ answered = false,
+ submittedAnswer,
+ compact = false,
+ disabled = false,
+ onSubmit,
+}: ChatQuestionResponseProps) {
+ const { t } = useTranslation("app");
+ const [answers, setAnswers] = useState({});
+ const textareaRefs = useRef(new Map());
+
+ const isValid = useMemo(
+ () => parsed.questions.every((question) => isQuestionAnswerValid(question, answers[question.id])),
+ [answers, parsed.questions],
+ );
+
+ useLayoutEffect(() => {
+ for (const textarea of textareaRefs.current.values()) {
+ textarea.style.height = "0";
+ textarea.style.height = `${textarea.scrollHeight}px`;
+ }
+ }, [answers]);
+
+ const setQuestionAnswer = useCallback((questionId: string, value: ChatQuestionAnswerValue) => {
+ setAnswers((current) => ({ ...current, [questionId]: value }));
+ }, []);
+
+ const toggleMultiSelect = useCallback((questionId: string, optionId: string, checked: boolean) => {
+ setAnswers((current) => {
+ const currentValue = current[questionId];
+ const selected = Array.isArray(currentValue) ? currentValue : [];
+ return {
+ ...current,
+ [questionId]: checked ? [...selected, optionId] : selected.filter((id) => id !== optionId),
+ };
+ });
+ }, []);
+
+ const handleSubmit = useCallback(() => {
+ if (!isValid || answered || disabled) {
+ return;
+ }
+
+ const answerText = formatQuestionAnswer(parsed.questions, answers);
+ onSubmit(answerText, answers);
+ }, [answers, answered, disabled, isValid, onSubmit, parsed.questions]);
+
+ return (
+
+
+ {t("chat.questionResponseEyebrow", "Assistant question")}
+ {answered && {t("chat.questionAnsweredLabel", "Answered")}}
+
+
+
+ {parsed.questions.map((question, questionIndex) => (
+
+ {question.header && {question.header}
}
+ {question.question}
+ {question.description && {question.description}
}
+
+ {answered ? null : (
+
+ )}
+
+ ))}
+
+
+ {answered ? (
+
+
{t("chat.questionSubmittedAnswerLabel", "Submitted answer")}
+
{submittedAnswer || t("chat.questionAnsweredWithoutContent", "A later user reply answered this question.")}
+
+ ) : (
+
+
{t("chat.questionSelectHint", "Answer all questions to continue the chat.")}
+
+
+ )}
+
+ );
+}
+
+interface QuestionControlsProps {
+ question: ChatQuestion;
+ questionIndex: number;
+ value: ChatQuestionAnswerValue | undefined;
+ disabled: boolean;
+ setQuestionAnswer: (questionId: string, value: ChatQuestionAnswerValue) => void;
+ toggleMultiSelect: (questionId: string, optionId: string, checked: boolean) => void;
+ textareaRefs: MutableRefObject
)}
- {renderToolCalls(streamingToolCalls, true, t)}
+ {renderToolCalls(streamingToolCalls, true, t, {
+ isAwaitingAnswer: true,
+ onQuestionSubmit: handleQuestionSubmit,
+ })}
{streamingThinking && (
{t("chat.thinkingLabel", "Thinking")}
@@ -2962,7 +3028,7 @@ export function QuickChatFAB({
{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}
) : (
<>
- {displayedMessages.map((message: ChatMessageInfo) => (
+ {displayedMessages.map((message: ChatMessageInfo, index) => (
))}
{helpMessageVisible && (
@@ -2985,7 +3054,7 @@ export function QuickChatFAB({
{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}
) : (
<>
- {displayedMessages.map((message: ChatMessageInfo) => (
+ {displayedMessages.map((message: ChatMessageInfo, index) => (
))}
{helpMessageVisible && (
diff --git a/packages/dashboard/app/components/__tests__/ChatQuestionResponse.test.tsx b/packages/dashboard/app/components/__tests__/ChatQuestionResponse.test.tsx
new file mode 100644
index 0000000000..10734771a0
--- /dev/null
+++ b/packages/dashboard/app/components/__tests__/ChatQuestionResponse.test.tsx
@@ -0,0 +1,52 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { ChatQuestionResponse } from "../ChatQuestionResponse";
+import type { ParsedQuestionToolCall } from "../../utils/parseQuestionToolCall";
+
+const parsed: ParsedQuestionToolCall = {
+ questions: [
+ { id: "single", type: "single_select", question: "Pick one", options: [{ id: "a", label: "Alpha" }, { id: "b", label: "Beta", description: "Second" }] },
+ { id: "multi", type: "multi_select", question: "Pick many", options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }] },
+ { id: "text", type: "text", question: "Explain" },
+ { id: "confirm", type: "confirm", question: "Proceed?" },
+ ],
+};
+
+describe("ChatQuestionResponse", () => {
+ it("renders all question controls and validates before submit", async () => {
+ const user = userEvent.setup();
+ const onSubmit = vi.fn();
+ render();
+
+ expect(screen.getByTestId("chat-question-response")).toBeInTheDocument();
+ const submit = screen.getByTestId("chat-question-response-submit");
+ expect(submit).toBeDisabled();
+
+ await user.click(screen.getByTestId("chat-question-response-option-single-a"));
+ await user.click(screen.getByTestId("chat-question-response-option-multi-x"));
+ await user.type(screen.getByTestId("chat-question-response-text-text"), "Need the safe path");
+ await user.click(screen.getByTestId("chat-question-response-option-confirm-yes"));
+
+ expect(submit).toBeEnabled();
+ await user.click(submit);
+
+ expect(onSubmit).toHaveBeenCalledWith(
+ "> Q: Pick one\nAlpha\n\n> Q: Pick many\nX\n\n> Q: Explain\nNeed the safe path\n\n> Q: Proceed?\nYes",
+ { single: "a", multi: ["x"], text: "Need the safe path", confirm: true },
+ );
+ });
+
+ it("renders an answered read-only summary", () => {
+ render();
+
+ expect(screen.getByText("Answered")).toBeInTheDocument();
+ expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Alpha");
+ expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
+ });
+
+ it("supports compact mode", () => {
+ render();
+ expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");
+ });
+});
diff --git a/packages/dashboard/app/components/__tests__/ChatView.test.tsx b/packages/dashboard/app/components/__tests__/ChatView.test.tsx
index bdd2910dcf..89f2482bd2 100644
--- a/packages/dashboard/app/components/__tests__/ChatView.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ChatView.test.tsx
@@ -1039,6 +1039,57 @@ describe("ChatView", () => {
expect(details?.querySelector(".chat-tool-call-status-text")).toHaveTextContent("completed");
});
+ it("renders latest question tool calls as inline response UI and sends answers", async () => {
+ const sendMessage = vi.fn();
+ setupMockChat({
+ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Question Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
+ sendMessage,
+ messages: [
+ {
+ id: "msg-001",
+ sessionId: "session-001",
+ role: "assistant",
+ content: "Need input",
+ toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }],
+ createdAt: "2026-04-08T00:00:00.000Z",
+ },
+ ],
+ });
+
+ await renderWithAct();
+
+ expect(screen.getByTestId("chat-question-response")).toBeInTheDocument();
+ expect(document.querySelector(".chat-tool-call")).not.toBeInTheDocument();
+
+ await userEvent.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
+ await userEvent.click(screen.getByTestId("chat-question-response-submit"));
+
+ expect(sendMessage).toHaveBeenCalledWith("> Q: Pick?\nAlpha");
+ });
+
+ it("renders historical question tool calls read-only with submitted answer", async () => {
+ setupMockChat({
+ activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Question Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
+ messages: [
+ {
+ id: "msg-001",
+ sessionId: "session-001",
+ role: "assistant",
+ content: "Need input",
+ toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }],
+ createdAt: "2026-04-08T00:00:00.000Z",
+ },
+ { id: "msg-002", sessionId: "session-001", role: "user", content: "> Q: Pick?\nBeta", createdAt: "2026-04-08T00:01:00.000Z" },
+ ],
+ });
+
+ await renderWithAct();
+
+ expect(screen.getByTestId("chat-question-response")).toHaveTextContent("Answered");
+ expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Beta");
+ expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
+ });
+
it("truncates tool names when more than 5 unique", async () => {
setupMockChat({
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
diff --git a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx
index f9ce5670ff..8e45ca2663 100644
--- a/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx
+++ b/packages/dashboard/app/components/__tests__/QuickChatFAB.test.tsx
@@ -180,6 +180,70 @@ describe("QuickChatFAB session-first UX", () => {
]);
});
+ it("renders compact question tool calls and sends answers through quick chat", async () => {
+ mockFetchChatMessages.mockResolvedValue({
+ messages: [{
+ id: "msg-question",
+ sessionId: "session-model",
+ role: "assistant",
+ content: "Need input",
+ metadata: { toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }] },
+ createdAt: "2026-05-16T00:00:00.000Z",
+ }],
+ });
+
+ render();
+ fireEvent.click(screen.getByTestId("quick-chat-fab"));
+
+ expect(await screen.findByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");
+ expect(document.querySelector(".chat-tool-call")).not.toBeInTheDocument();
+
+ fireEvent.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
+ fireEvent.click(screen.getByTestId("chat-question-response-submit"));
+
+ await waitFor(() => {
+ expect(mockStreamChatResponse).toHaveBeenCalledWith(
+ "session-model",
+ "> Q: Pick?\nAlpha",
+ expect.any(Object),
+ undefined,
+ "proj-1",
+ );
+ });
+ });
+
+ it("keeps non-question quick chat tool calls generic and historical questions read-only", async () => {
+ mockFetchChatMessages.mockResolvedValue({
+ messages: [
+ {
+ id: "msg-tool",
+ sessionId: "session-model",
+ role: "assistant",
+ content: "Read file",
+ metadata: { toolCalls: [{ toolName: "read", args: { path: "foo.ts" }, isError: false, status: "completed" }] },
+ createdAt: "2026-05-16T00:00:02.000Z",
+ },
+ { id: "msg-user", sessionId: "session-model", role: "user", content: "> Q: Pick?\nBeta", createdAt: "2026-05-16T00:00:01.000Z" },
+ {
+ id: "msg-question",
+ sessionId: "session-model",
+ role: "assistant",
+ content: "Need input",
+ metadata: { toolCalls: [{ toolName: "ask_user", args: { question: "Pick?", options: ["Alpha", "Beta"] }, isError: false, status: "completed" }] },
+ createdAt: "2026-05-16T00:00:00.000Z",
+ },
+ ],
+ });
+
+ render();
+ fireEvent.click(screen.getByTestId("quick-chat-fab"));
+
+ expect(await screen.findByTestId("chat-question-response")).toHaveTextContent("Answered");
+ expect(screen.getByTestId("chat-question-response-submitted-answer")).toHaveTextContent("Beta");
+ expect(screen.queryByTestId("chat-question-response-submit")).not.toBeInTheDocument();
+ expect(screen.getByText("read")).toBeInTheDocument();
+ });
+
it("removes header mode toggle and renders session dropdown", async () => {
render();
fireEvent.click(screen.getByTestId("quick-chat-fab"));
diff --git a/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts
new file mode 100644
index 0000000000..8aeb9d67a6
--- /dev/null
+++ b/packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import type { ToolCallInfo } from "../../hooks/chatTypes";
+import { formatQuestionAnswer, isQuestionToolName, parseQuestionToolCall } from "../parseQuestionToolCall";
+
+function toolCall(toolName: string, args?: Record): ToolCallInfo {
+ return { toolName, args, isError: false, status: "completed" };
+}
+
+describe("parseQuestionToolCall", () => {
+ it("recognizes question tool names case-insensitively", () => {
+ expect(isQuestionToolName("AskUserQuestion")).toBe(true);
+ expect(isQuestionToolName("ASK_USER")).toBe(true);
+ expect(isQuestionToolName("grep")).toBe(false);
+ });
+
+ it("normalizes Claude AskUserQuestion multi-question args", () => {
+ const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
+ questions: [
+ { question: "Pick one", header: "Decision", options: [{ label: "A" }, { label: "B", description: "Bee" }] },
+ { id: "features", question: "Pick many", options: [{ id: "x", label: "X" }, { label: "Y" }], multiSelect: true },
+ ],
+ }));
+
+ expect(parsed).toEqual({
+ questions: [
+ {
+ id: "q-0",
+ type: "single_select",
+ question: "Pick one",
+ header: "Decision",
+ description: undefined,
+ options: [{ id: "opt-0", label: "A", description: undefined }, { id: "opt-1", label: "B", description: "Bee" }],
+ multiSelect: undefined,
+ },
+ {
+ id: "features",
+ type: "multi_select",
+ question: "Pick many",
+ header: undefined,
+ description: undefined,
+ options: [{ id: "x", label: "X", description: undefined }, { id: "opt-1", label: "Y", description: undefined }],
+ multiSelect: true,
+ },
+ ],
+ });
+ });
+
+ it.each([
+ ["ask_user", { question: "Continue?", options: ["Yes", "No"] }, "confirm"],
+ ["request_user_input", { prompt: "Name?" }, "text"],
+ ["elicit", { message: "Choose", choices: [{ value: "a", label: "Alpha" }] }, "single_select"],
+ ["ask_followup_question", { question: "Boolean?", type: "boolean" }, "confirm"],
+ ] as const)("normalizes %s common schema", (name, args, expectedType) => {
+ const parsed = parseQuestionToolCall(toolCall(name, args));
+ expect(parsed?.questions).toHaveLength(1);
+ expect(parsed?.questions[0]?.type).toBe(expectedType);
+ expect(parsed?.questions[0]?.id).toBe("q-0");
+ });
+
+ 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("formats selected labels, text, and confirm answers", () => {
+ const parsed = parseQuestionToolCall(toolCall("AskUserQuestion", {
+ questions: [
+ { id: "one", question: "Pick one", options: [{ id: "a", label: "Alpha" }] },
+ { id: "many", question: "Pick many", options: [{ id: "x", label: "X" }, { id: "y", label: "Y" }], multiSelect: true },
+ { id: "text", question: "Explain" },
+ { id: "ok", question: "Proceed?", type: "confirm" },
+ ],
+ }));
+
+ expect(parsed).not.toBeNull();
+ expect(formatQuestionAnswer(parsed!.questions, { one: "a", many: ["x", "y"], text: "Because", ok: false })).toBe(
+ "> Q: Pick one\nAlpha\n\n> Q: Pick many\nX, Y\n\n> Q: Explain\nBecause\n\n> Q: Proceed?\nNo",
+ );
+ });
+});
diff --git a/packages/dashboard/app/utils/parseQuestionToolCall.ts b/packages/dashboard/app/utils/parseQuestionToolCall.ts
new file mode 100644
index 0000000000..beb529df08
--- /dev/null
+++ b/packages/dashboard/app/utils/parseQuestionToolCall.ts
@@ -0,0 +1,240 @@
+import type { PlanningQuestionType } from "@fusion/core";
+import type { ToolCallInfo } from "../hooks/chatTypes";
+
+export const QUESTION_TOOL_NAMES = [
+ "AskUserQuestion",
+ "ask_user",
+ "ask_followup_question",
+ "request_user_input",
+ "elicit",
+ "ask_question",
+] as const;
+
+const QUESTION_TOOL_NAME_SET = new Set(QUESTION_TOOL_NAMES.map((name) => name.toLowerCase()));
+
+export interface ChatQuestionOption {
+ id: string;
+ label: string;
+ description?: string;
+}
+
+export interface ChatQuestion {
+ id: string;
+ type: PlanningQuestionType;
+ question: string;
+ header?: string;
+ description?: string;
+ options?: ChatQuestionOption[];
+ multiSelect?: boolean;
+}
+
+export interface ParsedQuestionToolCall {
+ questions: ChatQuestion[];
+}
+
+export type ChatQuestionAnswerValue = string | string[] | boolean;
+export type ChatQuestionAnswers = Record;
+
+/**
+ * FNXC:ChatQuestionResponse 2026-06-16-19:18:
+ * Chat question tools from multiple agent CLIs must render as structured response controls in both ChatView and QuickChatFAB 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.
+ */
+export function isQuestionToolName(name: string): boolean {
+ return QUESTION_TOOL_NAME_SET.has(name.toLowerCase());
+}
+
+export function parseQuestionToolCall(toolCall: ToolCallInfo): ParsedQuestionToolCall | null {
+ if (!isQuestionToolName(toolCall.toolName)) {
+ return null;
+ }
+
+ const args = asRecord(toolCall.args);
+ if (!args) {
+ return null;
+ }
+
+ const rawQuestions = Array.isArray(args.questions) ? args.questions : null;
+ const questions = rawQuestions
+ ? rawQuestions.map((rawQuestion, index) => normalizeQuestion(rawQuestion, index)).filter(isChatQuestion)
+ : [normalizeQuestion(args, 0)].filter(isChatQuestion);
+
+ return questions.length > 0 ? { questions } : null;
+}
+
+export function formatQuestionAnswer(questions: ChatQuestion[], answers: ChatQuestionAnswers): string {
+ return questions
+ .map((question) => {
+ const answer = answers[question.id];
+ return `> Q: ${question.question}\n${formatAnswerValue(question, answer)}`;
+ })
+ .join("\n\n");
+}
+
+function normalizeQuestion(rawValue: unknown, index: number): ChatQuestion | null {
+ const raw = asRecord(rawValue);
+ if (!raw) {
+ return null;
+ }
+
+ const questionText = firstString(raw.question, raw.prompt, raw.message, raw.text, raw.title);
+ if (!questionText) {
+ return null;
+ }
+
+ 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);
+
+ if ((type === "single_select" || type === "multi_select") && options.length === 0) {
+ return null;
+ }
+
+ return {
+ id: firstString(raw.id, raw.name, raw.key) ?? `q-${index}`,
+ type,
+ question: questionText,
+ header: firstString(raw.header, raw.heading) ?? undefined,
+ description: firstString(raw.description, raw.details, raw.helpText) ?? undefined,
+ options: options.length > 0 ? options : undefined,
+ multiSelect: type === "multi_select" ? true : multiSelect || undefined,
+ };
+}
+
+function inferQuestionType(
+ raw: Record,
+ options: ChatQuestionOption[],
+ explicitType: PlanningQuestionType | null,
+ multiSelect: boolean,
+): PlanningQuestionType {
+ if (explicitType) {
+ if (explicitType === "multi_select" && options.length === 0) return "text";
+ if (explicitType === "single_select" && options.length === 0) return "text";
+ return explicitType;
+ }
+
+ if (isBooleanSchema(raw, options)) {
+ return "confirm";
+ }
+
+ if (options.length > 0) {
+ return multiSelect ? "multi_select" : "single_select";
+ }
+
+ return "text";
+}
+
+function normalizeQuestionType(value: string | null): PlanningQuestionType | null {
+ if (!value) return null;
+ const normalized = value.toLowerCase().replace(/[\s-]+/g, "_");
+ if (normalized === "text" || normalized === "free_text" || normalized === "input") return "text";
+ if (normalized === "single_select" || normalized === "select" || normalized === "choice") return "single_select";
+ if (normalized === "multi_select" || normalized === "multiple_select" || normalized === "checkbox") return "multi_select";
+ if (normalized === "confirm" || normalized === "confirmation" || normalized === "boolean" || normalized === "yes_no") return "confirm";
+ return null;
+}
+
+function isBooleanSchema(raw: Record, options: ChatQuestionOption[]): boolean {
+ const rawType = firstString(raw.type, raw.schemaType, raw.inputType, raw.responseType)?.toLowerCase().replace(/[\s-]+/g, "_");
+ if (rawType === "boolean" || rawType === "confirm" || rawType === "confirmation" || rawType === "yes_no") {
+ return true;
+ }
+
+ const schema = asRecord(raw.schema) ?? asRecord(raw.inputSchema) ?? asRecord(raw.parameters);
+ const schemaType = firstString(schema?.type, schema?.format)?.toLowerCase().replace(/[\s-]+/g, "_");
+ if (schemaType === "boolean" || schemaType === "yes_no") {
+ return true;
+ }
+
+ if (options.length !== 2) {
+ return false;
+ }
+
+ const labels = options.map((option) => option.label.trim().toLowerCase());
+ return labels.includes("yes") && labels.includes("no");
+}
+
+function normalizeOptions(rawOptions: unknown[] | null): ChatQuestionOption[] {
+ if (!rawOptions) return [];
+
+ return rawOptions
+ .map((rawOption, index) => {
+ const raw = asRecord(rawOption);
+ if (!raw) {
+ if (typeof rawOption === "string" || typeof rawOption === "number" || typeof rawOption === "boolean") {
+ return { id: `opt-${index}`, label: String(rawOption) };
+ }
+ return null;
+ }
+
+ const label = firstString(raw.label, raw.text, raw.name, raw.title, raw.value, raw.id);
+ if (!label) {
+ return null;
+ }
+
+ return {
+ id: firstString(raw.id, raw.value, raw.key) ?? `opt-${index}`,
+ label,
+ description: firstString(raw.description, raw.details, raw.helpText) ?? undefined,
+ };
+ })
+ .filter(isChatQuestionOption);
+}
+
+function formatAnswerValue(question: ChatQuestion, answer: ChatQuestionAnswerValue | undefined): string {
+ if (answer === undefined) {
+ return "(no answer)";
+ }
+
+ if (question.type === "confirm") {
+ return answer === true ? "Yes" : "No";
+ }
+
+ if (Array.isArray(answer)) {
+ const selectedLabels = answer.map((id) => optionLabelForId(question, id)).filter(Boolean);
+ return selectedLabels.length > 0 ? selectedLabels.join(", ") : "(no answer)";
+ }
+
+ if (question.type === "single_select") {
+ return optionLabelForId(question, String(answer)) ?? String(answer);
+ }
+
+ return String(answer).trim() || "(no answer)";
+}
+
+function optionLabelForId(question: ChatQuestion, id: string): string | null {
+ return question.options?.find((option) => option.id === id)?.label ?? null;
+}
+
+function asRecord(value: unknown): Record | null {
+ return value && typeof value === "object" && !Array.isArray(value) ? value as Record : null;
+}
+
+function firstString(...values: unknown[]): string | null {
+ for (const value of values) {
+ if (typeof value === "string" && value.trim().length > 0) {
+ return value.trim();
+ }
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value);
+ }
+ }
+ return null;
+}
+
+function firstArray(...values: unknown[]): unknown[] | null {
+ return values.find(Array.isArray) ?? null;
+}
+
+function isChatQuestion(value: ChatQuestion | null): value is ChatQuestion {
+ return value !== null;
+}
+
+function isChatQuestionOption(value: ChatQuestionOption | null): value is ChatQuestionOption {
+ return value !== null;
+}
diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json
index 3d97e8d4dc..284c42ed8f 100644
--- a/packages/i18n/locales/en/app.json
+++ b/packages/i18n/locales/en/app.json
@@ -1250,6 +1250,16 @@
"noSkillsAvailable": "No skills available",
"noSkillsFound": "No skills found",
"openQuickChat": "Open quick chat",
+ "questionAnsweredLabel": "Answered",
+ "questionAnsweredWithoutContent": "A later user reply answered this question.",
+ "questionConfirmNo": "No",
+ "questionConfirmYes": "Yes",
+ "questionResponseEyebrow": "Assistant question",
+ "questionResponseLabel": "Question from assistant",
+ "questionSelectHint": "Answer all questions to continue the chat.",
+ "questionSubmit": "Send answer",
+ "questionSubmittedAnswerLabel": "Submitted answer",
+ "questionTextPlaceholder": "Type your answer here…",
"queuedMessage": "Queued: {{preview}}",
"quickChatTitle": "Quick Chat",
"relativeTimeDays_one": "{{count}}d ago",