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
This commit is contained in:
5
.changeset/fn-6501-chat-question-response.md
Normal file
5
.changeset/fn-6501-chat-question-response.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": minor
|
||||
---
|
||||
|
||||
Render assistant question tool calls as shared in-chat response cards in full Chat and Quick Chat.
|
||||
@@ -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.
|
||||
|
||||
194
packages/dashboard/app/components/ChatQuestionResponse.css
Normal file
194
packages/dashboard/app/components/ChatQuestionResponse.css
Normal file
@@ -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%;
|
||||
}
|
||||
}
|
||||
247
packages/dashboard/app/components/ChatQuestionResponse.tsx
Normal file
247
packages/dashboard/app/components/ChatQuestionResponse.tsx
Normal file
@@ -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<string, unknown>) => 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<ChatQuestionAnswers>({});
|
||||
const textareaRefs = useRef(new Map<string, HTMLTextAreaElement>());
|
||||
|
||||
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 (
|
||||
<section
|
||||
className={`chat-question-response${compact ? " chat-question-response--compact" : ""}${answered ? " chat-question-response--answered" : ""}`}
|
||||
data-testid="chat-question-response"
|
||||
aria-label={t("chat.questionResponseLabel", "Question from assistant")}
|
||||
>
|
||||
<div className="chat-question-response__header">
|
||||
<span className="chat-question-response__eyebrow">{t("chat.questionResponseEyebrow", "Assistant question")}</span>
|
||||
{answered && <span className="chat-question-response__answered-label">{t("chat.questionAnsweredLabel", "Answered")}</span>}
|
||||
</div>
|
||||
|
||||
<div className="chat-question-response__questions">
|
||||
{parsed.questions.map((question, questionIndex) => (
|
||||
<article className="chat-question-response__question" key={question.id}>
|
||||
{question.header && <p className="chat-question-response__question-header">{question.header}</p>}
|
||||
<h4 className="chat-question-response__question-text">{question.question}</h4>
|
||||
{question.description && <p className="chat-question-response__description">{question.description}</p>}
|
||||
|
||||
{answered ? null : (
|
||||
<QuestionControls
|
||||
question={question}
|
||||
questionIndex={questionIndex}
|
||||
value={answers[question.id]}
|
||||
disabled={disabled}
|
||||
setQuestionAnswer={setQuestionAnswer}
|
||||
toggleMultiSelect={toggleMultiSelect}
|
||||
textareaRefs={textareaRefs}
|
||||
/>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{answered ? (
|
||||
<div className="chat-question-response__submitted" data-testid="chat-question-response-submitted-answer">
|
||||
<span className="chat-question-response__submitted-label">{t("chat.questionSubmittedAnswerLabel", "Submitted answer")}</span>
|
||||
<pre>{submittedAnswer || t("chat.questionAnsweredWithoutContent", "A later user reply answered this question.")}</pre>
|
||||
</div>
|
||||
) : (
|
||||
<div className="chat-question-response__actions">
|
||||
<p className="chat-question-response__hint">{t("chat.questionSelectHint", "Answer all questions to continue the chat.")}</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary chat-question-response__submit"
|
||||
data-testid="chat-question-response-submit"
|
||||
disabled={!isValid || disabled}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
{t("chat.questionSubmit", "Send answer")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
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<Map<string, HTMLTextAreaElement>>;
|
||||
}
|
||||
|
||||
function QuestionControls({
|
||||
question,
|
||||
questionIndex,
|
||||
value,
|
||||
disabled,
|
||||
setQuestionAnswer,
|
||||
toggleMultiSelect,
|
||||
textareaRefs,
|
||||
}: QuestionControlsProps) {
|
||||
const { t } = useTranslation("app");
|
||||
|
||||
if (question.type === "text") {
|
||||
return (
|
||||
<textarea
|
||||
className="input chat-question-response__textarea"
|
||||
data-testid={`chat-question-response-text-${question.id}`}
|
||||
placeholder={t("chat.questionTextPlaceholder", "Type your answer here…")}
|
||||
value={typeof value === "string" ? value : ""}
|
||||
disabled={disabled}
|
||||
rows={3}
|
||||
ref={(element) => {
|
||||
if (element) {
|
||||
textareaRefs.current.set(question.id, element);
|
||||
} else {
|
||||
textareaRefs.current.delete(question.id);
|
||||
}
|
||||
}}
|
||||
onChange={(event) => setQuestionAnswer(question.id, event.target.value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (question.type === "confirm") {
|
||||
return (
|
||||
<div className="chat-question-response__confirm-group" role="group" aria-label={question.question}>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn chat-question-response__confirm${value === true ? " chat-question-response__confirm--selected" : ""}`}
|
||||
data-testid={`chat-question-response-option-${question.id}-yes`}
|
||||
disabled={disabled}
|
||||
onClick={() => setQuestionAnswer(question.id, true)}
|
||||
>
|
||||
{t("chat.questionConfirmYes", "Yes")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn chat-question-response__confirm${value === false ? " chat-question-response__confirm--selected" : ""}`}
|
||||
data-testid={`chat-question-response-option-${question.id}-no`}
|
||||
disabled={disabled}
|
||||
onClick={() => setQuestionAnswer(question.id, false)}
|
||||
>
|
||||
{t("chat.questionConfirmNo", "No")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const options = question.options ?? [];
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
const radioName = `chat-question-${question.id}-${questionIndex}`;
|
||||
const isMulti = question.type === "multi_select";
|
||||
|
||||
return (
|
||||
<div className="chat-question-response__options" role={isMulti ? "group" : "radiogroup"} aria-label={question.question}>
|
||||
{options.map((option) => {
|
||||
const checked = isMulti ? selectedValues.includes(option.id) : value === option.id;
|
||||
return (
|
||||
<label
|
||||
key={option.id}
|
||||
className={`chat-question-response__option${checked ? " chat-question-response__option--selected" : ""}`}
|
||||
data-testid={`chat-question-response-option-${question.id}-${option.id}`}
|
||||
>
|
||||
<input
|
||||
type={isMulti ? "checkbox" : "radio"}
|
||||
name={isMulti ? undefined : radioName}
|
||||
value={option.id}
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(event) => {
|
||||
if (isMulti) {
|
||||
toggleMultiSelect(question.id, option.id, event.target.checked);
|
||||
} else {
|
||||
setQuestionAnswer(question.id, option.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span className="chat-question-response__option-content">
|
||||
<span className="chat-question-response__option-label">{option.label}</span>
|
||||
{option.description && <span className="chat-question-response__option-description">{option.description}</span>}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isQuestionAnswerValid(question: ChatQuestion, value: ChatQuestionAnswerValue | undefined): boolean {
|
||||
if (question.type === "text") {
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
|
||||
if (question.type === "multi_select") {
|
||||
return Array.isArray(value) && value.length > 0;
|
||||
}
|
||||
|
||||
if (question.type === "confirm") {
|
||||
return typeof value === "boolean";
|
||||
}
|
||||
|
||||
return typeof value === "string" && value.trim().length > 0;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { useViewportMode } from "./Header";
|
||||
import { updateGlobalSettings, type DiscoveredSkill } from "../api";
|
||||
import type { Agent } from "@fusion/core";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { AgentAvatar } from "./AgentAvatar";
|
||||
@@ -48,6 +49,7 @@ import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
import { useNavigationHistoryContext } from "../hooks/useNavigationHistory";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
import { recordResumeEvent } from "../utils/resumeInstrumentation";
|
||||
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
|
||||
@@ -258,10 +260,33 @@ function renderFailureReference(reference: FailureInfo["reference"], t: (key: st
|
||||
);
|
||||
}
|
||||
|
||||
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string): ReactNode {
|
||||
function renderToolCalls(
|
||||
toolCalls: ToolCallInfo[] | undefined,
|
||||
t: (key: string, defaultValue: string, opts?: Record<string, unknown>) => string,
|
||||
options?: {
|
||||
isAwaitingAnswer?: boolean;
|
||||
submittedAnswer?: string;
|
||||
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
||||
},
|
||||
): ReactNode {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||||
const parsedQuestion = parseQuestionToolCall(toolCall);
|
||||
if (parsedQuestion) {
|
||||
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
|
||||
return (
|
||||
<ChatQuestionResponse
|
||||
key={`${toolCall.toolName}-${index}`}
|
||||
parsed={parsedQuestion}
|
||||
answered={!isAwaitingAnswer}
|
||||
submittedAnswer={options?.submittedAnswer}
|
||||
disabled={!isAwaitingAnswer}
|
||||
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isRunning = toolCall.status === "running";
|
||||
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||
@@ -742,6 +767,13 @@ interface ChatMessageItemProps {
|
||||
roomContext: RoomContext | null;
|
||||
copyAction?: ReactNode;
|
||||
onScrollToTop?: (messageId: string) => void;
|
||||
isAwaitingQuestionAnswer: boolean;
|
||||
submittedQuestionAnswer?: string;
|
||||
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
|
||||
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
|
||||
}
|
||||
|
||||
// Renders a single chat message bubble. Memoized so the streaming bubble's
|
||||
@@ -760,6 +792,9 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
roomContext,
|
||||
copyAction,
|
||||
onScrollToTop,
|
||||
isAwaitingQuestionAnswer,
|
||||
submittedQuestionAnswer,
|
||||
onQuestionSubmit,
|
||||
}: ChatMessageItemProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isAssistantMessage = message.role === "assistant";
|
||||
@@ -923,7 +958,11 @@ const ChatMessageItem = memo(function ChatMessageItem({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{renderToolCalls(message.toolCalls, t)}
|
||||
{renderToolCalls(message.toolCalls, t, {
|
||||
isAwaitingAnswer: isAwaitingQuestionAnswer,
|
||||
submittedAnswer: submittedQuestionAnswer,
|
||||
onQuestionSubmit,
|
||||
})}
|
||||
{message.thinkingOutput && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
@@ -2033,6 +2072,31 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
|
||||
handleSend();
|
||||
}, [messageInput, pendingAttachments, chatRoomsEnabled, chatScope, rooms, rooms.clearRoom, clearComposerState, addToast, handleSend]);
|
||||
|
||||
const handleQuestionSubmit = useCallback(async (answerText: string) => {
|
||||
if (chatRoomsEnabled && chatScope === "rooms") {
|
||||
if (!rooms.activeRoom) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await rooms.sendRoomMessage(answerText);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: t("chat.failedToSendRoomMessage", "Failed to send room message");
|
||||
addToast(message, "error");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!activeSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendMessage(answerText);
|
||||
}, [activeSession, addToast, chatRoomsEnabled, chatScope, rooms, sendMessage, t]);
|
||||
|
||||
const handleSkillSelect = useCallback(
|
||||
(skill: DiscoveredSkill) => {
|
||||
setMessageInput((currentInput) => {
|
||||
@@ -2748,7 +2812,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
{isStreaming ? (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -2763,6 +2827,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
|
||||
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
))}
|
||||
<div className="chat-message chat-message--assistant chat-message--streaming">
|
||||
@@ -2781,7 +2848,10 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
</div>
|
||||
)}
|
||||
{showProviderResponseCopy && streamingText && renderCopyAction("__streaming__", streamingText, "chat-copy-response-streaming")}
|
||||
{renderToolCalls(streamingToolCalls, t)}
|
||||
{renderToolCalls(streamingToolCalls, t, {
|
||||
isAwaitingAnswer: true,
|
||||
onQuestionSubmit: handleQuestionSubmit,
|
||||
})}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking">
|
||||
<summary>{t("chat.thinking", "Thinking")}</summary>
|
||||
@@ -2803,7 +2873,7 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
<div className="chat-empty-state">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{messages.map((message) => (
|
||||
{messages.map((message, index) => (
|
||||
<ChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -2818,6 +2888,9 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
roomContext={null}
|
||||
copyAction={showProviderResponseCopy && message.role === "assistant" ? renderCopyAction(message.id, message.content) : undefined}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant" && index === messages.length - 1 && !isStreaming}
|
||||
submittedQuestionAnswer={findSubmittedQuestionAnswer(messages, index)}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
@@ -3454,6 +3527,8 @@ export function ChatView({ projectId, addToast, experimentalFeatures }: ChatView
|
||||
mentionAgentsByName={mentionAgentsByName}
|
||||
roomContext={roomContext}
|
||||
onScrollToTop={handleScrollMessageToTop}
|
||||
isAwaitingQuestionAnswer={false}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -19,6 +19,7 @@ import { ChevronDown, Eye, EyeOff, Hash, MessageSquare, Paperclip, Plus, Send, S
|
||||
import { attachmentBaseUrlForRoom, type Agent, type ModelInfo } from "../api";
|
||||
import type { DiscoveredSkill } from "@fusion/dashboard";
|
||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||
import { ChatQuestionResponse } from "./ChatQuestionResponse";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||
import { matchesAgentMentionFilter } from "./mentionMatching";
|
||||
@@ -36,6 +37,7 @@ import { useChatRooms } from "../hooks/useChatRooms";
|
||||
import { useChatUnread } from "../hooks/useChatUnread";
|
||||
import { getPersistedLastQuickChatSessionId } from "../hooks/quickChatLastSessionStorage";
|
||||
import { linkifyFilePaths, linkifyReactChildren } from "../utils/filePathLinkify";
|
||||
import { parseQuestionToolCall } from "../utils/parseQuestionToolCall";
|
||||
|
||||
interface PendingAttachment {
|
||||
file: File;
|
||||
@@ -148,10 +150,35 @@ function formatToolResultSummary(result: unknown): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
function renderToolCalls(toolCalls: ToolCallInfo[] | undefined, compact: boolean, t: TFunction<"app">): ReactNode {
|
||||
function renderToolCalls(
|
||||
toolCalls: ToolCallInfo[] | undefined,
|
||||
compact: boolean,
|
||||
t: TFunction<"app">,
|
||||
options?: {
|
||||
isAwaitingAnswer?: boolean;
|
||||
submittedAnswer?: string;
|
||||
onQuestionSubmit?: (answerText: string, structured: Record<string, unknown>) => void;
|
||||
},
|
||||
): ReactNode {
|
||||
if (!toolCalls || toolCalls.length === 0) return null;
|
||||
|
||||
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||||
const parsedQuestion = parseQuestionToolCall(toolCall);
|
||||
if (parsedQuestion) {
|
||||
const isAwaitingAnswer = options?.isAwaitingAnswer === true;
|
||||
return (
|
||||
<ChatQuestionResponse
|
||||
key={`${toolCall.toolName}-${index}`}
|
||||
parsed={parsedQuestion}
|
||||
compact={compact}
|
||||
answered={!isAwaitingAnswer}
|
||||
submittedAnswer={options?.submittedAnswer}
|
||||
disabled={!isAwaitingAnswer}
|
||||
onSubmit={(answerText, structured) => options?.onQuestionSubmit?.(answerText, structured)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const isRunning = toolCall.status === "running";
|
||||
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||
@@ -795,10 +822,17 @@ interface QuickChatMessageItemProps {
|
||||
roomContext: QuickChatRoomContext | null;
|
||||
projectId?: string;
|
||||
onToggleRender: (id: string) => void;
|
||||
isAwaitingQuestionAnswer: boolean;
|
||||
submittedQuestionAnswer?: string;
|
||||
onQuestionSubmit: (answerText: string, structured: Record<string, unknown>) => void;
|
||||
}
|
||||
|
||||
// Memoized so streaming state churn doesn't re-render every prior message
|
||||
// (each one would re-run ReactMarkdown over its full content otherwise).
|
||||
function findSubmittedQuestionAnswer(messages: ChatMessageInfo[], messageIndex: number): string | undefined {
|
||||
return messages.slice(messageIndex + 1).find((message) => message.role === "user")?.content;
|
||||
}
|
||||
|
||||
const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
message,
|
||||
forcePlain,
|
||||
@@ -806,6 +840,9 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
roomContext,
|
||||
projectId,
|
||||
onToggleRender,
|
||||
isAwaitingQuestionAnswer,
|
||||
submittedQuestionAnswer,
|
||||
onQuestionSubmit,
|
||||
}: QuickChatMessageItemProps) {
|
||||
const { t } = useTranslation("app");
|
||||
const isSent = message.role === "user";
|
||||
@@ -907,7 +944,11 @@ const QuickChatMessageItem = memo(function QuickChatMessageItem({
|
||||
</>
|
||||
)}
|
||||
{renderedAttachments}
|
||||
{renderToolCalls(message.toolCalls, true, t)}
|
||||
{renderToolCalls(message.toolCalls, true, t, {
|
||||
isAwaitingAnswer: isAwaitingQuestionAnswer,
|
||||
submittedAnswer: submittedQuestionAnswer,
|
||||
onQuestionSubmit,
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -2122,6 +2163,25 @@ export function QuickChatFAB({
|
||||
stopStreaming,
|
||||
]);
|
||||
|
||||
const handleQuestionSubmit = useCallback(async (answerText: string) => {
|
||||
try {
|
||||
setHelpMessageVisible(false);
|
||||
if (chatRoomsEnabled && roomsState.activeRoom) {
|
||||
await roomsState.sendRoomMessage(answerText);
|
||||
} else {
|
||||
await sendMessage(answerText);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
: (chatRoomsEnabled && roomsState.activeRoom ? t("chat.sendRoomMessageFailed", "Failed to send room message") : t("chat.sendMessageFailed", "Failed to send message"));
|
||||
addToast(message, "error");
|
||||
} finally {
|
||||
focusComposerInput();
|
||||
preserveComposerFocusRef.current = false;
|
||||
}
|
||||
}, [addToast, chatRoomsEnabled, focusComposerInput, roomsState, sendMessage, t]);
|
||||
|
||||
const handleAttachmentDragEnter = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
dragDepthRef.current += 1;
|
||||
@@ -2907,7 +2967,7 @@ export function QuickChatFAB({
|
||||
<div className="quick-chat-panel-empty">{t("chat.loadingConversation", "Loading conversation…")}</div>
|
||||
) : !roomThreadActive && isStreaming ? (
|
||||
<>
|
||||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||||
{displayedMessages.map((message: ChatMessageInfo, index) => (
|
||||
<QuickChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -2916,6 +2976,9 @@ export function QuickChatFAB({
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
|
||||
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
))}
|
||||
{helpMessageVisible && (
|
||||
@@ -2947,7 +3010,10 @@ export function QuickChatFAB({
|
||||
{streamingThinking ? t("chat.thinkingStatus", "Thinking…") : t("chat.connectingStatus", "Connecting…")}
|
||||
</p>
|
||||
)}
|
||||
{renderToolCalls(streamingToolCalls, true, t)}
|
||||
{renderToolCalls(streamingToolCalls, true, t, {
|
||||
isAwaitingAnswer: true,
|
||||
onQuestionSubmit: handleQuestionSubmit,
|
||||
})}
|
||||
{streamingThinking && (
|
||||
<details className="chat-message-thinking" data-testid="quick-chat-streaming-thinking">
|
||||
<summary>{t("chat.thinkingLabel", "Thinking")}</summary>
|
||||
@@ -2962,7 +3028,7 @@ export function QuickChatFAB({
|
||||
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||||
{displayedMessages.map((message: ChatMessageInfo, index) => (
|
||||
<QuickChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -2971,6 +3037,9 @@ export function QuickChatFAB({
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
|
||||
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
))}
|
||||
{helpMessageVisible && (
|
||||
@@ -2985,7 +3054,7 @@ export function QuickChatFAB({
|
||||
<div className="quick-chat-panel-empty">{t("chat.noMessagesYet", "No messages yet. Start the conversation!")}</div>
|
||||
) : (
|
||||
<>
|
||||
{displayedMessages.map((message: ChatMessageInfo) => (
|
||||
{displayedMessages.map((message: ChatMessageInfo, index) => (
|
||||
<QuickChatMessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
@@ -2994,6 +3063,9 @@ export function QuickChatFAB({
|
||||
roomContext={roomContext}
|
||||
projectId={projectId}
|
||||
onToggleRender={toggleMessageRenderMode}
|
||||
isAwaitingQuestionAnswer={message.role === "assistant" && index === displayedMessages.length - 1 && !isStreaming}
|
||||
submittedQuestionAnswer={findSubmittedQuestionAnswer(displayedMessages, index)}
|
||||
onQuestionSubmit={handleQuestionSubmit}
|
||||
/>
|
||||
))}
|
||||
{helpMessageVisible && (
|
||||
|
||||
@@ -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(<ChatQuestionResponse parsed={parsed} onSubmit={onSubmit} />);
|
||||
|
||||
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(<ChatQuestionResponse parsed={parsed} answered submittedAnswer="> Q: Pick one\nAlpha" onSubmit={vi.fn()} />);
|
||||
|
||||
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(<ChatQuestionResponse parsed={{ questions: [parsed.questions[0]!] }} compact onSubmit={vi.fn()} />);
|
||||
expect(screen.getByTestId("chat-question-response")).toHaveClass("chat-question-response--compact");
|
||||
});
|
||||
});
|
||||
@@ -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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
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(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||
|
||||
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" },
|
||||
|
||||
@@ -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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
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(<QuickChatFAB addToast={vi.fn()} projectId="proj-1" />);
|
||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||
|
||||
@@ -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<string, unknown>): 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
240
packages/dashboard/app/utils/parseQuestionToolCall.ts
Normal file
240
packages/dashboard/app/utils/parseQuestionToolCall.ts
Normal file
@@ -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<string, ChatQuestionAnswerValue>;
|
||||
|
||||
/**
|
||||
* 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<string, unknown>,
|
||||
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<string, unknown>, 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<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : 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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user