diff --git a/.changeset/fn-7379-planner-question-cards.md b/.changeset/fn-7379-planner-question-cards.md
new file mode 100644
index 0000000000..56c97da663
--- /dev/null
+++ b/.changeset/fn-7379-planner-question-cards.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Make Planner Chat clarification questions answerable in task details.
+category: fix
+dev: Extracts fn_ask_question cards from grouped tool-call details in shared chat rendering.
diff --git a/packages/dashboard/app/components/StandardChatSurface.tsx b/packages/dashboard/app/components/StandardChatSurface.tsx
index f443a7bf16..7d3a6faddc 100644
--- a/packages/dashboard/app/components/StandardChatSurface.tsx
+++ b/packages/dashboard/app/components/StandardChatSurface.tsx
@@ -183,6 +183,10 @@ export function renderStandardToolCalls(
},
): ReactNode {
if (!toolCalls || toolCalls.length === 0) return null;
+ /*
+ FNXC:StandardChatSurface 2026-07-01-09:20:
+ Planner Chat and regular Chat must surface `fn_ask_question` as an actionable ChatQuestionResponse even when the model also calls tools such as `bash`. Question cards render outside the collapsed grouped tool-call details so users are not stranded on summary text, while non-question tools keep their generic visibility.
+ */
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
const custom = options?.toolCallRenderer?.(toolCall, index);
if (custom !== undefined) return custom;
@@ -221,29 +225,54 @@ export function renderStandardToolCalls(
);
};
- if (toolCalls.length === 1) {
- return
•{t("chat.toolCallsHeader", "Tool calls")}
{renderToolCallItem(toolCalls[0], 0)}
;
- }
- const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length;
- const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
- const hasRunning = runningCount > 0;
- const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName)));
- const visibleNames = uniqueNames.slice(0, 5);
- const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length);
- const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", ");
- const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null;
+ const questionEntries: ReactNode[] = [];
+ const nonQuestionEntries: Array<{ toolCall: ToolCallInfo; index: number }> = [];
+ toolCalls.forEach((toolCall, index) => {
+ if (parseQuestionToolCall(toolCall)) {
+ const renderedQuestion = renderToolCallItem(toolCall, index);
+ if (renderedQuestion !== null && renderedQuestion !== undefined && renderedQuestion !== false) {
+ questionEntries.push(renderedQuestion);
+ }
+ return;
+ }
+ nonQuestionEntries.push({ toolCall, index });
+ });
+ const renderNonQuestionToolCalls = (): ReactNode => {
+ if (nonQuestionEntries.length === 0) return null;
+ if (nonQuestionEntries.length === 1) {
+ const entry = nonQuestionEntries[0]!;
+ return •{t("chat.toolCallsHeader", "Tool calls")}
{renderToolCallItem(entry.toolCall, entry.index)}
;
+ }
+ const nonQuestionToolCalls = nonQuestionEntries.map((entry) => entry.toolCall);
+ const runningCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "running").length;
+ const errorCount = nonQuestionToolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
+ const hasRunning = runningCount > 0;
+ const uniqueNames = Array.from(new Set(nonQuestionToolCalls.map((toolCall) => toolCall.toolName)));
+ const visibleNames = uniqueNames.slice(0, 5);
+ const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length);
+ const namesSummary = overflowCount > 0 ? `${visibleNames.join(", ")}, +${overflowCount} more` : visibleNames.join(", ");
+ const statusSummary = hasRunning ? `(${runningCount} ${t("chat.toolCallStatusRunning", "running")})` : errorCount > 0 ? `(${errorCount} ${errorCount === 1 ? t("chat.toolCallStatusError", "error") : t("chat.toolCallStatusErrors", "errors")})` : null;
+ return (
+
+
+
+ •
+ {t("chat.toolCallsCount", "{{count}} tool calls", { count: nonQuestionToolCalls.length })}
+ {namesSummary}
+ {statusSummary && {statusSummary}}
+
+ {nonQuestionEntries.map(({ toolCall, index }) => renderToolCallItem(toolCall, index))}
+
+
+ );
+ };
+ const renderedNonQuestionToolCalls = renderNonQuestionToolCalls();
+ if (questionEntries.length === 0) return renderedNonQuestionToolCalls;
return (
-
-
-
- •
- {t("chat.toolCallsCount", "{{count}} tool calls", { count: toolCalls.length })}
- {namesSummary}
- {statusSummary && {statusSummary}}
-
- {toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
-
-
+ <>
+ {questionEntries}
+ {renderedNonQuestionToolCalls}
+ >
);
}
diff --git a/packages/dashboard/app/components/TaskPlannerChatTab.tsx b/packages/dashboard/app/components/TaskPlannerChatTab.tsx
index 319eb03764..b71a0f66f8 100644
--- a/packages/dashboard/app/components/TaskPlannerChatTab.tsx
+++ b/packages/dashboard/app/components/TaskPlannerChatTab.tsx
@@ -691,6 +691,9 @@ export function TaskPlannerChatTab({ task, projectId, active, expanded = false,
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.
+ FNXC:TaskDetailPlannerChat 2026-07-01-09:20:
+ Task-detail Planner Chat must keep `fn_ask_question` actionable when streamed or persisted alongside other tools such as `bash`. The planner renderer owns task-scoped answer submission and dedupe while StandardChatSurface extracts the question card outside grouped tool-call details.
+
FNXC:TaskDetailPlannerChat 2026-07-01-09:34:
Planner Chat delegates transcript bubbles, thinking details, tool-call framing, and mobile send/stop gestures to StandardChatSurface. TaskPlannerChatTab keeps lookup-only session loading, task-context sends, starter prompts, and steering confirmations local so reuse does not collapse the lazy ChatView chunk or merge planner chat with Activity.
diff --git a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
index 1d5bf3af97..ff1866359b 100644
--- a/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskPlannerChatTab.test.tsx
@@ -847,17 +847,32 @@ describe("TaskPlannerChatTab", () => {
expect(screen.queryByTestId("task-planner-chat-empty")).not.toBeInTheDocument();
});
- it("renders planner question tool calls with the shared answer UI", async () => {
+ it("renders mixed persisted planner question tool calls with the shared answer UI outside collapsed details", async () => {
const user = userEvent.setup();
mockFetchChatMessages.mockResolvedValue({
- messages: [plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] })],
+ messages: [{
+ ...plannerQuestionMessage("assistant-question", { question: "Pick a path", options: ["Conservative", "Aggressive"] }),
+ metadata: {
+ toolCalls: [
+ { toolName: "bash", args: { command: "echo hi" }, isError: false, result: "hi", status: "completed" },
+ { toolName: "fn_ask_question", args: { question: "Pick a path", options: ["Conservative", "Aggressive"] }, isError: false, status: "completed" },
+ ],
+ },
+ }],
});
renderPlannerChat();
- expect(await screen.findByTestId("chat-question-response")).toBeInTheDocument();
+ const transcript = await screen.findByTestId("task-planner-chat-transcript");
+ const question = await screen.findByTestId("chat-question-response");
+ expect(transcript).toContainElement(question);
+ expect(question).toHaveTextContent("Pick a path");
+ expect(question.closest("details.chat-tool-calls-group")).toBeNull();
+ expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash");
+ expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question");
await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
await user.click(screen.getByTestId("chat-question-response-submit"));
+ expect(mockStreamChatResponse).toHaveBeenCalledTimes(1);
expect(mockStreamChatResponse).toHaveBeenCalledWith(
"chat-planner",
"> Q: Pick a path\nConservative",
@@ -962,11 +977,13 @@ describe("TaskPlannerChatTab", () => {
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
});
- it("streams risky change requests as clarification questions without steering mutation", async () => {
+ it("streams mixed planner question tool calls as an actionable card outside collapsed details", async () => {
const user = userEvent.setup();
const onTaskUpdated = vi.fn();
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
+ handlers.onToolStart({ toolName: "bash", args: { command: "echo preparing" } });
+ handlers.onToolEnd({ toolName: "bash", isError: false, result: "preparing" });
handlers.onToolStart({ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] } });
handlers.onDone({
messageId: "assistant-risky-question",
@@ -976,7 +993,12 @@ describe("TaskPlannerChatTab", () => {
role: "assistant",
content: "I need clarification before adding steering.",
thinkingOutput: null,
- metadata: { toolCalls: [{ toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] }, isError: false }] },
+ metadata: {
+ toolCalls: [
+ { toolName: "bash", args: { command: "echo preparing" }, isError: false, result: "preparing", status: "completed" },
+ { toolName: "fn_ask_question", args: { question: "Which files and safety constraints should this destructive change use?", options: ["Clarify scope", "Cancel"] }, isError: false, status: "completed" },
+ ],
+ },
createdAt: "2026-06-30T00:03:00.000Z",
},
});
@@ -989,9 +1011,23 @@ describe("TaskPlannerChatTab", () => {
await user.type(screen.getByLabelText("Message planner chat"), "Delete the risky parts and rewrite the security flow broadly");
await user.click(screen.getByRole("button", { name: "Send" }));
- expect(await screen.findByTestId("chat-question-response")).toHaveTextContent("Which files and safety constraints should this destructive change use?");
+ const question = await screen.findByTestId("chat-question-response");
+ expect(question).toHaveTextContent("Which files and safety constraints should this destructive change use?");
+ expect(question.closest("details.chat-tool-calls-group")).toBeNull();
expect(screen.getAllByTestId("chat-question-response")).toHaveLength(1);
expect(screen.getAllByTestId("chat-question-response-submit")).toHaveLength(1);
+ expect(screen.getByTestId("chat-tool-calls")).toHaveTextContent("bash");
+ expect(screen.getByTestId("chat-tool-calls")).not.toHaveTextContent("fn_ask_question");
+ await user.click(screen.getByTestId("chat-question-response-option-q-0-opt-0"));
+ await user.click(screen.getByTestId("chat-question-response-submit"));
+ expect(mockStreamChatResponse).toHaveBeenLastCalledWith(
+ "chat-planner",
+ "> Q: Which files and safety constraints should this destructive change use?\nClarify scope",
+ expect.any(Object),
+ undefined,
+ "project-1",
+ { taskId: "FN-7310" },
+ );
expect(screen.queryByTestId("task-planner-chat-steering-confirmation")).not.toBeInTheDocument();
expect(mockFetchTaskDetail).not.toHaveBeenCalled();
expect(onTaskUpdated).not.toHaveBeenCalled();