From a4537167abf97d28694661d3677aee68246c38c4 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 16 Jun 2026 20:13:13 -0700 Subject: [PATCH] FN-6501: render chat question response controls Render structured assistant question prompts directly in chat surfaces. - Add parser and formatter support for question tool calls. - Add reusable question response UI for select, multi-select, text, and confirm prompts. - Wire regular chat and quick chat to show live and answered question states. - Cover parsing and chat response behavior with dashboard tests and localized labels. Files changed: .changeset/fn-6501-chat-question-response.md | 5 + docs/dashboard-guide.md | 2 + .../app/components/ChatQuestionResponse.css | 194 ++++++++++++++++ .../app/components/ChatQuestionResponse.tsx | 247 +++++++++++++++++++++ packages/dashboard/app/components/ChatView.tsx | 85 ++++++- packages/dashboard/app/components/QuickChatFAB.tsx | 84 ++++++- .../__tests__/ChatQuestionResponse.test.tsx | 52 +++++ .../app/components/__tests__/ChatView.test.tsx | 51 +++++ .../app/components/__tests__/QuickChatFAB.test.tsx | 64 ++++++ .../utils/__tests__/parseQuestionToolCall.test.ts | 82 +++++++ .../dashboard/app/utils/parseQuestionToolCall.ts | 240 ++++++++++++++++++++ packages/i18n/locales/en/app.json | 10 + 12 files changed, 1105 insertions(+), 11 deletions(-) Fusion-Task-Id: FN-6501 Fusion-Task-Lineage: 1fd4a3aa-b2d7-4157-a196-852fdeda680d --- .changeset/fn-6501-chat-question-response.md | 5 + docs/dashboard-guide.md | 2 + .../app/components/ChatQuestionResponse.css | 194 ++++++++++++++ .../app/components/ChatQuestionResponse.tsx | 247 ++++++++++++++++++ .../dashboard/app/components/ChatView.tsx | 85 +++++- .../dashboard/app/components/QuickChatFAB.tsx | 84 +++++- .../__tests__/ChatQuestionResponse.test.tsx | 52 ++++ .../components/__tests__/ChatView.test.tsx | 51 ++++ .../__tests__/QuickChatFAB.test.tsx | 64 +++++ .../__tests__/parseQuestionToolCall.test.ts | 82 ++++++ .../app/utils/parseQuestionToolCall.ts | 240 +++++++++++++++++ packages/i18n/locales/en/app.json | 10 + 12 files changed, 1105 insertions(+), 11 deletions(-) create mode 100644 .changeset/fn-6501-chat-question-response.md create mode 100644 packages/dashboard/app/components/ChatQuestionResponse.css create mode 100644 packages/dashboard/app/components/ChatQuestionResponse.tsx create mode 100644 packages/dashboard/app/components/__tests__/ChatQuestionResponse.test.tsx create mode 100644 packages/dashboard/app/utils/__tests__/parseQuestionToolCall.test.ts create mode 100644 packages/dashboard/app/utils/parseQuestionToolCall.ts 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>; +} + +function QuestionControls({ + question, + questionIndex, + value, + disabled, + setQuestionAnswer, + toggleMultiSelect, + textareaRefs, +}: QuestionControlsProps) { + const { t } = useTranslation("app"); + + if (question.type === "text") { + return ( +